Code Insights: Migrate dashboard chart cards to new chart and card components API (#33799)

* Create InsightCard component (abstraction for building insight card for the dashboard page)

* Migrate BuiltIn (runtime insight) card component

* Improve type safety over data fetching state

* Revamp backend insight api methods

* Move locked chart view to insight folder

* Update some of @visx packages
This commit is contained in:
Vova Kulikov 2022-04-15 13:06:50 +04:00 committed by GitHub
parent 1407ed3a31
commit fa2e2a7709
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 1194 additions and 666 deletions

View File

@ -110,7 +110,15 @@ const PlainChart = () => {
},
]
return <LineChart width={400} height={400} data={DATA} series={SERIES} getXValue={getXValue} />
return (
<div style={{ width: 400, height: 400 }}>
<ParentSize className="flex-1">
{({ width, height }) => (
<LineChart width={width} height={height} data={DATA} series={SERIES} getXValue={getXValue} />
)}
</ParentSize>
</div>
)
}
const PlainStackedChart = () => {

View File

@ -1,4 +1,4 @@
import React, { ReactElement, useMemo, useRef, useState, SVGProps } from 'react'
import React, { ReactElement, useMemo, useState, SVGProps } from 'react'
import { curveLinear } from '@visx/curve'
import { Group } from '@visx/group'
@ -51,8 +51,8 @@ export function LineChart<D>(props: LineChartContentProps<D>): ReactElement | nu
} = props
const [activePoint, setActivePoint] = useState<Point<D> & { element?: Element }>()
const yAxisReference = useRef<SVGGElement>(null)
const xAxisReference = useRef<SVGGElement>(null)
const [yAxisElement, setYAxisElement] = useState<SVGGElement | null>(null)
const [xAxisReference, setXAxisElement] = useState<SVGGElement | null>(null)
const { width, height, margin } = useMemo(
() =>
@ -62,11 +62,11 @@ export function LineChart<D>(props: LineChartContentProps<D>): ReactElement | nu
margin: {
top: 10,
right: 20,
left: yAxisReference.current?.getBoundingClientRect().width,
bottom: xAxisReference.current?.getBoundingClientRect().height,
left: yAxisElement?.getBoundingClientRect().width,
bottom: xAxisReference?.getBoundingClientRect().height,
},
}),
[outerWidth, outerHeight]
[yAxisElement, xAxisReference, outerWidth, outerHeight]
)
const dataSeries = useMemo(() => getSeriesData({ data, series, stacked, getXValue }), [
@ -147,7 +147,7 @@ export function LineChart<D>(props: LineChartContentProps<D>): ReactElement | nu
{...handlers}
>
<AxisLeft
ref={yAxisReference}
ref={setYAxisElement}
scale={yScale}
width={width}
height={height}
@ -155,7 +155,7 @@ export function LineChart<D>(props: LineChartContentProps<D>): ReactElement | nu
left={margin.left}
/>
<AxisBottom ref={xAxisReference} scale={xScale} top={margin.top + height} width={width} />
<AxisBottom ref={setXAxisElement} scale={xScale} top={margin.top + height} width={width} />
<NonActiveBackground
data={data}

View File

@ -1,21 +1,27 @@
import React from 'react'
import { bottomTickLabelProps } from '@visx/axis/lib/axis/AxisBottom'
import { leftTickLabelProps } from '@visx/axis/lib/axis/AxisLeft'
import { TickLabelProps, TickRendererProps } from '@visx/axis/lib/types'
import { TickLabelProps, TickRendererProps } from '@visx/axis'
import { Group } from '@visx/group'
import { Text } from '@visx/text'
import { TextProps } from '@visx/text/lib/Text'
import { Text, TextProps } from '@visx/text'
import { formatXLabel } from '../../utils'
export const getTickYProps: TickLabelProps<number> = (value, index, values): Partial<TextProps> => ({
...leftTickLabelProps(),
dx: '-0.25em',
dy: '0.25em',
fill: '#222',
fontFamily: 'Arial',
fontSize: 10,
textAnchor: 'end',
'aria-label': `Tick axis ${index + 1} of ${values.length}. Value: ${value}`,
})
export const getTickXProps: TickLabelProps<Date> = (value, index, values): Partial<TextProps> => ({
...bottomTickLabelProps(),
dy: '0.25em',
fill: '#222',
fontFamily: 'Arial',
fontSize: 10,
textAnchor: 'middle',
'aria-label': `Tick axis ${index + 1} of ${values.length}. Value: ${formatXLabel(value)}`,
})
@ -26,14 +32,14 @@ export const Tick: React.FunctionComponent<TickRendererProps> = props => {
const { formattedValue, ...tickLabelProps } = props
// Hack with Group + Text (aria hidden)
// Because the Text component renders text inside of svg element and text element with tspan
// Because the Text component renders text inside svg element and text element with tspan
// this makes another nested group for a11y tree. To avoid "group - end group"
// phrase in voice over we hide nested children from a11y tree and put explicit aria-label
// on the parent Group element with role text
return (
// eslint-disable-next-line jsx-a11y/aria-role
<Group role="text" aria-label={tickLabelProps['aria-label']}>
<Text aria-hidden={true} {...tickLabelProps}>
<Text aria-hidden={true} {...(tickLabelProps as TextProps)}>
{formattedValue}
</Text>
</Group>

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { LiHTMLAttributes } from 'react'
import classNames from 'classnames'
@ -18,18 +18,18 @@ export const LegendList: React.FunctionComponent<LegendListProps> = props => {
)
}
interface LegendItemProps {
interface LegendItemProps extends LiHTMLAttributes<HTMLLIElement> {
color: string
name: string
}
export const LegendItem: React.FunctionComponent<LegendItemProps> = props => (
<li className={styles.legendItem}>
export const LegendItem: React.FunctionComponent<LegendItemProps> = ({ color, name, className, ...attributes }) => (
<li {...attributes} className={classNames(styles.legendItem, className)}>
<div
/* eslint-disable-next-line react/forbid-dom-props */
style={{ backgroundColor: props.color }}
style={{ backgroundColor: color }}
className={styles.legendMark}
/>
{props.name}
{name}
</li>
)

View File

@ -20,19 +20,19 @@ export function generatePointsField<Datum>(input: PointsFieldInput<Datum>): Poin
return dataSeries.flatMap(series => {
const { data, dataKey, getLinkURL = NULL_LINK } = series
return (data as SeriesDatum<Datum>[]).filter(isDatumWithValidNumber).map((data, index) => {
const datumValue = getDatumValue(data)
return (data as SeriesDatum<Datum>[]).filter(isDatumWithValidNumber).map((datum, index) => {
const datumValue = getDatumValue(datum)
return {
id: `${dataKey as string}-${index}`,
seriesKey: dataKey as string,
value: datumValue,
y: yScale(datumValue),
x: xScale(data.x),
time: data.x,
x: xScale(datum.x),
time: datum.x,
color: series.color ?? 'green',
linkUrl: getLinkURL(data.datum, index),
datum: data.datum,
linkUrl: getLinkURL(datum.datum, index),
datum: datum.datum,
}
})
})

View File

@ -1,22 +0,0 @@
import React from 'react'
import classNames from 'classnames'
import styles from './AlertOverlay.module.scss'
export interface AlertOverlayProps {
title: string
description: string
icon?: React.ReactNode
}
export const AlertOverlay: React.FunctionComponent<AlertOverlayProps> = ({ title, description, icon }) => (
<>
<div className={classNames('position-absolute w-100 h-100', styles.gradient)} />
<div className="position-absolute d-flex flex-column justify-content-center align-items-center w-100 h-100">
{icon && <div className={styles.icon}>{icon}</div>}
<h4 className={styles.title}>{title}</h4>
<small className={styles.description}>{description}</small>
</div>
</>
)

View File

@ -2,7 +2,8 @@ import React from 'react'
import { Page } from '../../../../components/Page'
import { useUiFeatures } from '../../hooks/use-ui-features'
import { CodeInsightsLimitAccessBanner } from '../limit-access-banner/CodeInsightsLimitAccessBanner'
import { CodeInsightsLimitAccessBanner } from './limit-access-banner/CodeInsightsLimitAccessBanner'
interface CodeInsightsPageProps extends React.HTMLAttributes<HTMLDivElement> {}

View File

@ -1,98 +0,0 @@
import { getCollisions, Position } from '@reach/popover'
const DEFAULT_PADDING = 6
/**
* Custom popover position calculator. Returns position objects (top,left,right,bottom) styles
* with values such that the target and the popover element have the same right borders.
*
* <pre>
* ------------ | Target | --------
* ----|*****************| --------
* ----|*****************| --------
* ----|*** Popover *****| --------
* ----|*****************| --------
* ----|*****************| --------
* </pre>
*
* @param targetRectangle - bounding client rect of the target element
* @param popoverRectangle - bounding client rect of the pop-over element. All calculation props
* that are returned from this function will be applied to this element.
*/
export const positionBottomRight: Position = (targetRectangle, popoverRectangle) => {
if (!targetRectangle || !popoverRectangle) {
return {}
}
const { directionUp } = getCollisions(targetRectangle, popoverRectangle)
return {
left: `${targetRectangle.right - popoverRectangle.width + window.scrollX}px`,
top: directionUp
? `${targetRectangle.top - popoverRectangle.height + window.scrollY - DEFAULT_PADDING}px`
: `${targetRectangle.top + targetRectangle.height + window.scrollY + DEFAULT_PADDING}px`,
}
}
/**
* Custom position calculator with flip logic.
*
* <pre>
* In case if it's enough space at right
* --| Target ||*****************|--
* ------------|*****************|--
* ------------|**** Popover ****|--
* ------------|*****************|--
* ------------|*****************|--
*
* In other case if it's enough space at left side
* --|*****************|| Target |
* --|*****************|
* --|**** Popover ****|
* --|*****************|
* --|*****************|
*
* And as a fallback plan place it below the target
* ------------ | Target | --------
* ----|*****************| --------
* ----|*****************| --------
* ----|*** Popover *****| --------
* ----|*****************| --------
* ----|*****************| --------
* </pre>
*
* @param targetRectangle - bounding client rect of the target element
* @param popoverRectangle - bounding client rect of the pop-over element. All calculation props
* that are returned from this function will be applied to this element
*/
export const flipRightPosition: Position = (targetRectangle, popoverRectangle) => {
if (!targetRectangle || !popoverRectangle) {
return {}
}
const isEnoughSpaceLeft = targetRectangle.left - popoverRectangle.width > 0
const isEnoughSpaceRight = window.innerWidth > targetRectangle.right + popoverRectangle.width
const { directionUp } = getCollisions(targetRectangle, popoverRectangle)
if (isEnoughSpaceRight) {
return {
left: `${targetRectangle.right + window.scrollX + DEFAULT_PADDING}px`,
top: `${targetRectangle.top + window.scrollY - 4}px`,
}
}
if (isEnoughSpaceLeft) {
return {
left: `${targetRectangle.left - popoverRectangle.width + window.scrollX - DEFAULT_PADDING}px`,
top: `${targetRectangle.top + window.scrollY - 4}px`,
}
}
return {
left: `${targetRectangle.right - popoverRectangle.width + window.scrollX}px`,
top: directionUp
? `${targetRectangle.top - popoverRectangle.height + window.scrollY - DEFAULT_PADDING}px`
: `${targetRectangle.top + targetRectangle.height + window.scrollY + DEFAULT_PADDING}px`,
}
}

View File

@ -8,6 +8,7 @@
.insight-card {
min-height: 20rem;
position: relative;
}
.chart-block {

View File

@ -1,2 +1 @@
export { CodeInsightsIcon } from '../../../insights/Icons'
export { InsightsNavItem } from './insights-nav-link/InsightsNavLink'

View File

@ -1,14 +0,0 @@
import React from 'react'
import { LinkWithIcon } from '../../../../components/LinkWithIcon'
import { CodeInsightsIcon } from '../../../../insights/Icons'
export const InsightsNavItem: React.FunctionComponent = () => (
<LinkWithIcon
to="/insights"
text="Insights"
icon={CodeInsightsIcon}
className="nav-link text-decoration-none"
activeClassName="active"
/>
)

View File

@ -1,17 +1,13 @@
import React from 'react'
import { Meta } from '@storybook/react'
import { of } from 'rxjs'
import { Observable, of } from 'rxjs'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { WebStory } from '../../../../components/WebStory'
import {
LINE_CHART_TESTS_CASES_EXAMPLE,
LINE_CHART_WITH_HUGE_NUMBER_OF_LINES,
LINE_CHART_WITH_MANY_LINES,
} from '../../../../views/mocks/charts-content'
import { CodeInsightsBackendStoryMock } from '../../CodeInsightsBackendStoryMock'
import { BackendInsightData, SeriesChartContent } from '../../core'
import { BackendInsight, Insight, InsightExecutionType, InsightType, isCaptureGroupInsight } from '../../core/types'
import { SmartInsightsViewGrid } from './SmartInsightsViewGrid'
@ -137,26 +133,267 @@ const insightsWithManyLines: Insight[] = [
},
]
interface HugeLinesDatum {
x: number
a: number
b: number
c: number
d: number
e: number | null
f: number
g: number
h: number
i: number
j: number
k: number
l: number
m: number
n: number
o: number
p: number
q: number
r: number
s: number
t: number
}
const LINE_CHART_WITH_HUGE_NUMBER_OF_LINES: SeriesChartContent<HugeLinesDatum> = {
data: [
{
x: 1588965700286 - 4 * 24 * 60 * 60 * 1000,
a: 4000,
b: 15000,
c: 12000,
d: 11000,
e: 11000,
f: 13000,
g: 5000,
h: 5000,
i: 5000,
j: 7000,
k: 10000,
l: 8000,
m: 3900,
n: 3000,
o: 4000,
p: 5000,
q: 4500,
r: 5000,
s: 5500,
t: 6000,
},
{
x: 1588965700286 - 3 * 24 * 60 * 60 * 1000,
a: 4000,
b: 17000,
c: 14000,
d: 11000,
e: 11000,
f: 5000,
g: 5000,
h: 6000,
i: 5500,
j: 7200,
k: 8000,
l: 7800,
m: 4000,
n: 3000,
o: 4500,
p: 5500,
q: 5500,
r: 6000,
s: 7500,
t: 5000,
},
{
x: 1588965700286 - 2 * 24 * 60 * 60 * 1000,
a: 5600,
b: 20000,
c: 15000,
d: 13000,
e: null,
f: 23000,
g: 8000,
h: 7000,
i: 4500,
j: 11000,
k: 10000,
l: 9000,
m: 5000,
n: 3000,
o: 4000,
p: 5000,
q: 4500,
r: 5000,
s: 5500,
t: 6000,
},
{
x: 1588965700286 - 1 * 24 * 60 * 60 * 1000,
a: 9800,
b: 19000,
c: 9000,
d: 8000,
e: null,
f: 13000,
g: 5000,
h: 6000,
i: 5500,
j: 7200,
k: 8000,
l: 7800,
m: 4000,
n: 4000,
o: 5000,
p: 4000,
q: 7500,
r: 8000,
s: 8500,
t: 4000,
},
{
x: 1588965700286,
a: 12300,
b: 17000,
c: 8000,
d: 8500,
e: null,
f: 16000,
g: 9000,
h: 8000,
i: 5500,
j: 12000,
k: 11000,
l: 10000,
m: 6000,
n: 6000,
o: 7000,
p: 8000,
q: 6500,
r: 9000,
s: 10500,
t: 16000,
},
],
series: [
{
dataKey: 'a',
name: 'React functional components',
color: 'var(--green)',
},
{
dataKey: 'b',
name: 'Class components',
color: 'var(--orange)',
},
{ dataKey: 'c', name: 'useTheme adoption', color: 'var(--blue)' },
{ dataKey: 'd', name: 'Class without CSS modules', color: 'var(--purple)' },
{ dataKey: 'e', name: '1.11', color: 'var(--oc-grape-7)' },
{ dataKey: 'f', name: 'Functional components without CSS modules', color: 'var(--oc-red-7)' },
{ dataKey: 'g', name: '1.12', color: 'var(--pink)' },
{ dataKey: 'h', name: '1.13', color: 'var(--oc-violet-7)' },
{ dataKey: 'i', name: '1.14', color: 'var(--indigo)' },
{ dataKey: 'm', name: '1.15', color: 'var(--cyan)' },
{ dataKey: 'j', name: '1.16', color: 'var(--teal)' },
{ dataKey: 'k', name: '1.17', color: 'var(--oc-lime-7)' },
{ dataKey: 'l', name: '1.18', color: 'var(--yellow)' },
{ dataKey: 'n', name: '1.19', color: 'var(--oc-lime-7)' },
{ dataKey: 'o', name: '1.20', color: 'var(--oc-pink-7)' },
{ dataKey: 'p', name: '1.21', color: 'var(--oc-red-7)' },
{ dataKey: 'q', name: '1.22', color: 'var(--oc-blue-7)' },
{ dataKey: 'r', name: '1.23', color: 'var(--oc-grape-7)' },
{ dataKey: 's', name: '1.24', color: 'var(--oc-green-7)' },
{ dataKey: 't', name: '1.25', color: 'var(--oc-cyan-7)' },
],
getXValue: datum => new Date(datum.x),
}
interface ManyLinesDatum {
x: number
a: number
b: number
c: number
d: number
f: number
}
const LINE_CHART_WITH_MANY_LINES: SeriesChartContent<ManyLinesDatum> = {
data: [
{ x: 1588965700286 - 4 * 24 * 60 * 60 * 1000, a: 4000, b: 15000, c: 12000, d: 11000, f: 13000 },
{ x: 1588965700286 - 3 * 24 * 60 * 60 * 1000, a: 4000, b: 26000, c: 14000, d: 11000, f: 5000 },
{ x: 1588965700286 - 2 * 24 * 60 * 60 * 1000, a: 5600, b: 20000, c: 15000, d: 13000, f: 63000 },
{ x: 1588965700286 - 1 * 24 * 60 * 60 * 1000, a: 9800, b: 19000, c: 9000, d: 8000, f: 13000 },
{ x: 1588965700286, a: 12300, b: 17000, c: 8000, d: 8500, f: 16000 },
],
series: [
{
dataKey: 'a',
name: 'React functional components',
color: 'var(--warning)',
},
{
dataKey: 'b',
name: 'Class components',
color: 'var(--warning)',
},
{ dataKey: 'c', name: 'useTheme adoption', color: 'var(--blue)' },
{ dataKey: 'd', name: 'Class without CSS modules', color: 'var(--purple)' },
{ dataKey: 'f', name: 'Functional components without CSS modules', color: 'var(--green)' },
],
getXValue: datum => new Date(datum.x),
}
interface TestCasesDatum {
x: number
a: number
b: number
c: number
d: number
f: number
}
const LINE_CHART_TESTS_CASES_EXAMPLE: SeriesChartContent<TestCasesDatum> = {
data: [
{ x: 1588965700286 - 4 * 24 * 60 * 60 * 1000, a: 4000, b: 15000, c: 12000, d: 11000, f: 13000 },
{ x: 1588965700286 - 3 * 24 * 60 * 60 * 1000, a: 4000, b: 26000, c: 14000, d: 11000, f: 5000 },
{ x: 1588965700286 - 2 * 24 * 60 * 60 * 1000, a: 5600, b: 20000, c: 15000, d: 13000, f: 63000 },
{ x: 1588965700286 - 1 * 24 * 60 * 60 * 1000, a: 9800, b: 19000, c: 9000, d: 8000, f: 13000 },
{ x: 1588965700286, a: 12300, b: 17000, c: 8000, d: 8500, f: 16000 },
],
series: [
{
dataKey: 'a',
name: 'React Test renderer',
color: 'var(--blue)',
},
{
dataKey: 'b',
name: 'Enzyme',
color: 'var(--pink)',
},
{
dataKey: 'c',
name: 'React Testing Library',
color: 'var(--red)',
},
],
getXValue: datum => new Date(datum.x),
}
const codeInsightsApiWithManyLines = {
getBackendInsightData: (insight: BackendInsight) => {
getBackendInsightData: (insight: BackendInsight): Observable<BackendInsightData> => {
if (isCaptureGroupInsight(insight)) {
throw new Error('This demo does not support capture group insight')
}
return of({
id: insight.id,
view: {
title: 'Backend Insight Mock',
subtitle: 'Backend insight description text',
content: [
insight.series.length >= 6
? insight.series.length >= 15
? LINE_CHART_WITH_HUGE_NUMBER_OF_LINES
: LINE_CHART_WITH_MANY_LINES
: LINE_CHART_TESTS_CASES_EXAMPLE,
],
isFetchingHistoricalData: false,
},
content:
insight.series.length >= 6
? insight.series.length >= 15
? LINE_CHART_WITH_HUGE_NUMBER_OF_LINES
: LINE_CHART_WITH_MANY_LINES
: LINE_CHART_TESTS_CASES_EXAMPLE,
isFetchingHistoricalData: false,
})
},
}

View File

@ -6,10 +6,10 @@ import { Layout, Layouts } from 'react-grid-layout'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { ViewGrid } from '../../../../views'
import { Insight } from '../../core/types'
import { Insight } from '../../core'
import { getTrackingTypeByInsightType } from '../../pings'
import { SmartInsight } from './components/smart-insight/SmartInsight'
import { SmartInsight } from './components/SmartInsight'
import { insightLayoutGenerator, recalculateGridLayout } from './utils/grid-layout-generator'
interface SmartInsightsViewGridProps extends TelemetryProps {

View File

@ -0,0 +1,138 @@
import React, { Ref, useContext, useMemo, useRef, useState } from 'react'
import { useMergeRefs } from 'use-callback-ref'
import { ErrorAlert } from '@sourcegraph/branded/src/components/alerts'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { useDeepMemo } from '@sourcegraph/wildcard'
import { ParentSize } from '../../../../../charts'
import { CodeInsightsBackendContext, LangStatsInsight, SearchRuntimeBasedInsight } from '../../../core'
import { InsightContentType } from '../../../core/types/insight/common'
import { useDeleteInsight } from '../../../hooks/use-delete-insight'
import { LazyQueryStatus } from '../../../hooks/use-parallel-requests/use-parallel-request'
import { useRemoveInsightFromDashboard } from '../../../hooks/use-remove-insight'
import { DashboardInsightsContext } from '../../../pages/dashboards/dashboard-page/components/dashboards-content/components/dashboard-inisghts/DashboardInsightsContext'
import { getTrackingTypeByInsightType, useCodeInsightViewPings } from '../../../pings'
import {
CategoricalBasedChartTypes,
CategoricalChart,
InsightCard,
InsightCardBanner,
InsightCardHeader,
InsightCardLegend,
InsightCardLoading,
SeriesBasedChartTypes,
SeriesChart,
} from '../../views'
import { useInsightData } from '../hooks/use-insight-data'
import { InsightContextMenu } from './insight-context-menu/InsightContextMenu'
interface BuiltInInsightProps extends TelemetryProps, React.HTMLAttributes<HTMLElement> {
insight: SearchRuntimeBasedInsight | LangStatsInsight
innerRef: Ref<HTMLElement>
resizing: boolean
}
/**
* Historically we had a few insights that were worked via extension API
* search-based, code-stats insight
*
* This component renders insight card that works almost like before with extensions
* Component sends FE network request to get and process information but does that in
* main work thread instead of using Extension API.
*/
export function BuiltInInsight(props: BuiltInInsightProps): React.ReactElement {
const { insight, resizing, telemetryService, innerRef, ...otherProps } = props
const { getBuiltInInsightData } = useContext(CodeInsightsBackendContext)
const { dashboard } = useContext(DashboardInsightsContext)
const insightCardReference = useRef<HTMLDivElement>(null)
const mergedInsightCardReference = useMergeRefs([insightCardReference, innerRef])
const cachedInsight = useDeepMemo(insight)
const { state, isVisible } = useInsightData(
useMemo(() => () => getBuiltInInsightData({ insight: cachedInsight }), [getBuiltInInsightData, cachedInsight]),
insightCardReference
)
// Visual line chart settings
const [zeroYAxisMin, setZeroYAxisMin] = useState(false)
const { delete: handleDelete, loading: isDeleting } = useDeleteInsight()
const { remove: handleRemove, loading: isRemoving } = useRemoveInsightFromDashboard()
const { trackDatumClicks, trackMouseLeave, trackMouseEnter } = useCodeInsightViewPings({
telemetryService,
insightType: getTrackingTypeByInsightType(insight.type),
})
return (
<InsightCard
{...otherProps}
ref={mergedInsightCardReference}
data-testid={`insight-card.${insight.id}`}
onMouseEnter={trackMouseEnter}
onMouseLeave={trackMouseLeave}
>
<InsightCardHeader title={insight.title}>
{isVisible && (
<InsightContextMenu
insight={insight}
dashboard={dashboard}
menuButtonClassName="ml-1 d-inline-flex"
zeroYAxisMin={zeroYAxisMin}
onToggleZeroYAxisMin={() => setZeroYAxisMin(!zeroYAxisMin)}
onRemoveFromDashboard={dashboard => handleRemove({ insight, dashboard })}
onDelete={() => handleDelete(insight)}
/>
)}
</InsightCardHeader>
{resizing ? (
<InsightCardBanner>Resizing</InsightCardBanner>
) : state.status === LazyQueryStatus.Loading || isDeleting || !isVisible ? (
<InsightCardLoading>{isDeleting ? 'Deleting code insight' : 'Loading code insight'}</InsightCardLoading>
) : isRemoving ? (
<InsightCardLoading>Removing insight from the dashboard</InsightCardLoading>
) : state.status === LazyQueryStatus.Error ? (
<ErrorAlert error={state.error} />
) : (
<>
<ParentSize>
{parent =>
state.data.type === InsightContentType.Series ? (
<SeriesChart
type={SeriesBasedChartTypes.Line}
width={parent.width}
height={parent.height}
zeroYAxisMin={zeroYAxisMin}
locked={insight.isFrozen}
onDatumClick={trackDatumClicks}
{...state.data.content}
/>
) : (
<CategoricalChart
type={CategoricalBasedChartTypes.Pie}
width={parent.width}
height={parent.height}
locked={insight.isFrozen}
onDatumLinkClick={trackDatumClicks}
{...state.data.content}
/>
)
}
</ParentSize>
{state.data.type === InsightContentType.Series && (
<InsightCardLegend series={state.data.content.series} className="mt-3" />
)}
</>
)}
{
// Passing children props explicitly to render any top-level content like
// resize-handler from the react-grid-layout library
isVisible && otherProps.children
}
</InsightCard>
)
}

View File

@ -2,9 +2,10 @@ import React, { forwardRef } from 'react'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Insight, isBackendInsight } from '../../../../core/types'
import { BackendInsightView } from '../backend-insight/BackendInsight'
import { BuiltInInsight } from '../built-in-insight/BuiltInInsight'
import { Insight, isBackendInsight } from '../../../core'
import { BackendInsightView } from './backend-insight/BackendInsight'
import { BuiltInInsight } from './BuiltInInsight'
export interface SmartInsightProps extends TelemetryProps, React.HTMLAttributes<HTMLElement> {
insight: Insight

View File

@ -1,24 +0,0 @@
import React from 'react'
import classNames from 'classnames'
import ProgressWrench from 'mdi-react/ProgressWrenchIcon'
import { AlertOverlay } from '../../../alert-overlay/AlertOverlay'
interface BackendAlertOverLayProps {
isFetchingHistoricalData?: boolean
hasNoData: boolean
}
export const BackendAlertOverlay: React.FunctionComponent<BackendAlertOverLayProps> = ({
isFetchingHistoricalData,
hasNoData,
}) =>
isFetchingHistoricalData ? (
<AlertOverlay
title="This insight is still being processed"
description="Datapoints shown may be undercounted."
icon={<ProgressWrench className={classNames('mb-3')} size={33} />}
/>
) : hasNoData ? (
<AlertOverlay title="No data to display" description="We couldnt find any matches for this insight." />
) : null

View File

@ -1,14 +1,14 @@
import React from 'react'
import { Meta, Story } from '@storybook/react'
import { of, throwError } from 'rxjs'
import { Observable, of, throwError } from 'rxjs'
import { delay } from 'rxjs/operators'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { WebStory } from '../../../../../../components/WebStory'
import { LINE_CHART_CONTENT_MOCK, LINE_CHART_CONTENT_MOCK_EMPTY } from '../../../../../../views/mocks/charts-content'
import { CodeInsightsBackendStoryMock } from '../../../../CodeInsightsBackendStoryMock'
import { BackendInsightData, SearchBackendBasedInsight, SeriesChartContent } from '../../../../core'
import { InsightInProcessError } from '../../../../core/backend/utils/errors'
import {
BackendInsight as BackendInsightType,
@ -16,7 +16,6 @@ import {
InsightType,
isCaptureGroupInsight,
} from '../../../../core/types'
import { SearchBackendBasedInsight } from '../../../../core/types/insight/types/search-insight'
import { BackendInsightView } from './BackendInsight'
@ -28,24 +27,72 @@ const defaultStory: Meta = {
export default defaultStory
const INSIGHT_CONFIGURATION_MOCK: SearchBackendBasedInsight = {
title: 'Mock Backend Insight',
series: [],
executionType: InsightExecutionType.Backend,
type: InsightType.SearchBased,
id: 'searchInsights.insight.mock_backend_insight_id',
title: 'Backend Insight Mock',
series: [],
type: InsightType.SearchBased,
executionType: InsightExecutionType.Backend,
step: { weeks: 2 },
filters: { excludeRepoRegexp: '', includeRepoRegexp: '' },
dashboardReferenceCount: 0,
isFrozen: false,
}
interface BackendInsightDatum {
x: number
a: number
b: number
linkA: string
}
const LINE_CHART_CONTENT_MOCK: SeriesChartContent<BackendInsightDatum> = {
data: [
{ x: 1588965700286 - 4 * 24 * 60 * 60 * 1000, a: 4000, b: 15000, linkA: '#A:1st_data_point' },
{ x: 1588965700286 - 3 * 24 * 60 * 60 * 1000, a: 4000, b: 26000, linkA: '#A:2st_data_point' },
{ x: 1588965700286 - 2 * 24 * 60 * 60 * 1000, a: 5600, b: 20000, linkA: '#A:3rd_data_point' },
{ x: 1588965700286 - 1 * 24 * 60 * 60 * 1000, a: 9800, b: 19000, linkA: '#A:4th_data_point' },
{ x: 1588965700286, a: 12300, b: 17000, linkA: '#A:5th_data_point' },
],
getXValue: datum => new Date(datum.x),
series: [
{
dataKey: 'a',
name: 'A metric',
color: 'var(--warning)',
getLinkURL: datum => datum.linkA,
},
{
dataKey: 'b',
name: 'B metric',
color: 'var(--warning)',
},
],
}
const LINE_CHART_CONTENT_MOCK_EMPTY: SeriesChartContent<BackendInsightDatum> = {
data: [],
getXValue: datum => new Date(datum.x),
series: [
{
dataKey: 'a',
name: 'A metric',
color: 'var(--warning)',
},
{
dataKey: 'b',
name: 'B metric',
color: 'var(--warning)',
},
],
}
const mockInsightAPI = ({
isFetchingHistoricalData = false,
delayAmount = 0,
throwProcessingError = false,
hasData = true,
} = {}) => ({
getBackendInsightData: (insight: BackendInsightType) => {
getBackendInsightData: (insight: BackendInsightType): Observable<BackendInsightData> => {
if (isCaptureGroupInsight(insight)) {
throw new Error('This demo does not support capture group insight')
}
@ -55,13 +102,8 @@ const mockInsightAPI = ({
}
return of({
id: insight.id,
view: {
title: 'Backend Insight Mock',
subtitle: 'Backend insight description text',
content: [hasData ? LINE_CHART_CONTENT_MOCK : LINE_CHART_CONTENT_MOCK_EMPTY],
isFetchingHistoricalData,
},
content: hasData ? LINE_CHART_CONTENT_MOCK : LINE_CHART_CONTENT_MOCK_EMPTY,
isFetchingHistoricalData,
}).pipe(delay(delayAmount))
},
})
@ -107,5 +149,16 @@ export const BackendInsight: Story = () => (
<TestBackendInsight />
</CodeInsightsBackendStoryMock>
</article>
<article className="mt-3">
<h2>Locked Card insight</h2>
<CodeInsightsBackendStoryMock mocks={mockInsightAPI()}>
<BackendInsightView
style={{ width: 400, height: 400 }}
insight={{ ...INSIGHT_CONFIGURATION_MOCK, isFrozen: true }}
telemetryService={NOOP_TELEMETRY_SERVICE}
innerRef={() => {}}
/>
</CodeInsightsBackendStoryMock>
</article>
</section>
)

View File

@ -3,28 +3,28 @@ import React, { Ref, useCallback, useContext, useRef, useState } from 'react'
import classNames from 'classnames'
import { useMergeRefs } from 'use-callback-ref'
import { asError, isErrorLike } from '@sourcegraph/common'
import { asError } from '@sourcegraph/common'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { useDebounce, Alert } from '@sourcegraph/wildcard'
import { useDebounce, useDeepMemo } from '@sourcegraph/wildcard'
import * as View from '../../../../../../views'
import { LineChartSettingsContext } from '../../../../../../views'
import { LockedChart } from '../../../../../../views/components/view/content/chart-view-content/charts/locked/LockedChart'
import { CodeInsightsBackendContext, BackendInsight, InsightFilters } from '../../../../core'
import { InsightInProcessError } from '../../../../core/backend/utils/errors'
import { BackendInsight, CodeInsightsBackendContext, InsightFilters } from '../../../../core'
import { useDeleteInsight } from '../../../../hooks/use-delete-insight'
import { useDistinctValue } from '../../../../hooks/use-distinct-value'
import { LazyQueryStatus } from '../../../../hooks/use-parallel-requests/use-parallel-request'
import { useRemoveInsightFromDashboard } from '../../../../hooks/use-remove-insight'
import { DashboardInsightsContext } from '../../../../pages/dashboards/dashboard-page/components/dashboards-content/components/dashboard-inisghts/DashboardInsightsContext'
import { useCodeInsightViewPings, getTrackingTypeByInsightType } from '../../../../pings'
import { getTrackingTypeByInsightType, useCodeInsightViewPings } from '../../../../pings'
import { FORM_ERROR, SubmissionErrors } from '../../../form/hooks/useForm'
import { InsightCard, InsightCardBanner, InsightCardHeader, InsightCardLoading } from '../../../views'
import { useInsightData } from '../../hooks/use-insight-data'
import { InsightContextMenu } from '../insight-context-menu/InsightContextMenu'
import { BackendAlertOverlay } from './BackendAlertOverlay'
import { DrillDownFiltersAction } from './components/drill-down-filters-action/DrillDownFiltersPanel'
import { DrillDownInsightCreationFormValues } from './components/drill-down-filters-panel/components/drill-down-insight-creation-form/DrillDownInsightCreationForm'
import { EMPTY_DRILLDOWN_FILTERS } from './components/drill-down-filters-panel/utils'
import {
BackendInsightErrorAlert,
EMPTY_DRILLDOWN_FILTERS,
DrillDownFiltersPopover,
DrillDownInsightCreationFormValues,
BackendInsightChart,
} from './components'
import styles from './BackendInsight.module.scss'
@ -54,7 +54,7 @@ export const BackendInsightView: React.FunctionComponent<BackendInsightProps> =
// Use deep copy check in case if a setting subject has re-created copy of
// the insight config with same structure and values. To avoid insight data
// re-fetching.
const cachedInsight = useDistinctValue(insight)
const cachedInsight = useDeepMemo(insight)
// Original insight filters values that are stored in setting subject with insight
// configuration object, They are updated whenever the user clicks update/save button
@ -66,10 +66,10 @@ export const BackendInsightView: React.FunctionComponent<BackendInsightProps> =
// filter value in filters fields.
const [filters, setFilters] = useState<InsightFilters>(originalInsightFilters)
const [isFiltersOpen, setIsFiltersOpen] = useState(false)
const debouncedFilters = useDebounce(useDistinctValue<InsightFilters>(filters), 500)
const debouncedFilters = useDebounce(useDeepMemo<InsightFilters>(filters), 500)
// Loading the insight backend data
const { data, loading, error, isVisible } = useInsightData(
const { state, isVisible } = useInsightData(
useCallback(
() =>
getBackendInsightData({
@ -139,14 +139,18 @@ export const BackendInsightView: React.FunctionComponent<BackendInsightProps> =
})
return (
<View.Root
<InsightCard
{...otherProps}
title={insight.title}
innerRef={mergedInsightCardReference}
actions={
isVisible && (
ref={mergedInsightCardReference}
data-testid={`insight-card.${insight.id}`}
className={classNames(otherProps.className, { [styles.cardWithFilters]: isFiltersOpen })}
onMouseEnter={trackMouseEnter}
onMouseLeave={trackMouseLeave}
>
<InsightCardHeader title={insight.title}>
{isVisible && (
<>
<DrillDownFiltersAction
<DrillDownFiltersPopover
isOpen={isFiltersOpen}
popoverTargetRef={insightCardReference}
initialFiltersValue={filters}
@ -166,50 +170,25 @@ export const BackendInsightView: React.FunctionComponent<BackendInsightProps> =
onDelete={() => handleDelete(insight)}
/>
</>
)
}
data-testid={`insight-card.${insight.id}`}
className={classNames(otherProps.className, { [styles.cardWithFilters]: isFiltersOpen })}
onMouseEnter={trackMouseEnter}
onMouseLeave={trackMouseLeave}
>
)}
</InsightCardHeader>
{resizing ? (
<View.Banner>Resizing</View.Banner>
) : loading || isDeleting || !isVisible ? (
<View.LoadingContent text={isDeleting ? 'Deleting code insight' : 'Loading code insight'} />
<InsightCardBanner>Resizing</InsightCardBanner>
) : state.status === LazyQueryStatus.Loading || isDeleting || !isVisible ? (
<InsightCardLoading>{isDeleting ? 'Deleting code insight' : 'Loading code insight'}</InsightCardLoading>
) : isRemoving ? (
<View.LoadingContent text="Removing insight from the dashboard" />
) : isErrorLike(error) ? (
<View.ErrorContent error={error} title={insight.id}>
{error instanceof InsightInProcessError ? (
<Alert className="m-0" variant="info">
{error.message}
</Alert>
) : null}
</View.ErrorContent>
) : insight.isFrozen ? (
<LockedChart />
<InsightCardLoading>Removing insight from the dashboard</InsightCardLoading>
) : state.status === LazyQueryStatus.Error ? (
<BackendInsightErrorAlert error={state.error} />
) : (
data && (
<LineChartSettingsContext.Provider value={{ zeroYAxisMin }}>
<View.Content
content={data.view.content}
alert={
<BackendAlertOverlay
hasNoData={!data.view.content.some(({ data }) => data.length > 0)}
isFetchingHistoricalData={data.view.isFetchingHistoricalData}
/>
}
onDatumLinkClick={trackDatumClicks}
/>
</LineChartSettingsContext.Provider>
)
<BackendInsightChart {...state.data} locked={insight.isFrozen} onDatumClick={trackDatumClicks} />
)}
{
// Passing children props explicitly to render any top-level content like
// resize-handler from the react-grid-layout library
isVisible && otherProps.children
}
</View.Root>
</InsightCard>
)
}

View File

@ -1,4 +1,6 @@
.gradient {
.alert-container {
position: relative;
:global(.theme-dark) & {
background: linear-gradient(180deg, rgba(25, 27, 37, 0.6) 0%, #191b25 51.56%, rgba(25, 27, 37, 0.6) 100%);
}
@ -8,6 +10,18 @@
}
}
.alert-content {
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.icon {
color: var(--gray-06);
}

View File

@ -0,0 +1,77 @@
import React from 'react'
import classNames from 'classnames'
import ProgressWrench from 'mdi-react/ProgressWrenchIcon'
import { ErrorAlert } from '@sourcegraph/branded/src/components/alerts'
import { ErrorLike } from '@sourcegraph/common'
import { Alert } from '@sourcegraph/wildcard'
import { InsightInProcessError } from '../../../../../../core/backend/utils/errors'
import styles from './BackendInsightAlerts.module.scss'
interface BackendAlertOverLayProps {
isFetchingHistoricalData: boolean
hasNoData: boolean
className?: string
}
export const BackendAlertOverlay: React.FunctionComponent<BackendAlertOverLayProps> = props => {
const { isFetchingHistoricalData, hasNoData, className } = props
if (isFetchingHistoricalData) {
return (
<AlertOverlay
title="This insight is still being processed"
description="Datapoints shown may be undercounted."
icon={<ProgressWrench className={classNames('mb-3')} size={33} />}
className={className}
/>
)
}
if (hasNoData) {
return (
<AlertOverlay
title="No data to display"
description="We couldnt find any matches for this insight."
className={className}
/>
)
}
return null
}
export interface AlertOverlayProps {
title: string
description: string
icon?: React.ReactNode
className?: string
}
const AlertOverlay: React.FunctionComponent<AlertOverlayProps> = props => {
const { title, description, icon, className } = props
return (
<div className={classNames(className, styles.alertContainer)}>
<div className={styles.alertContent}>
{icon && <div className={styles.icon}>{icon}</div>}
<h4 className={styles.title}>{title}</h4>
<small className={styles.description}>{description}</small>
</div>
</div>
)
}
interface BackendInsightErrorAlertProps {
error: ErrorLike
}
export const BackendInsightErrorAlert: React.FunctionComponent<BackendInsightErrorAlertProps> = props =>
props.error instanceof InsightInProcessError ? (
<Alert variant="info">{props.error.message}</Alert>
) : (
<ErrorAlert error={props.error} />
)

View File

@ -0,0 +1,69 @@
.chart {
flex: 1;
min-height: 0;
position: relative;
display: grid;
grid-template-columns: auto minmax(10rem, 30%);
grid-template-rows: auto 1fr;
gap: 0.75rem;
grid-template-areas:
'chart chart'
'chart chart'
'legend legend';
&--horizontal {
grid-template-areas:
'chart legend'
'chart legend';
.legend-list {
flex-wrap: nowrap;
flex-direction: column;
}
}
}
.responsive-container {
grid-area: chart;
position: relative;
overflow: hidden;
&:hover,
&:focus-within {
.alert-overlay {
display: none;
}
}
}
.legend-list-container {
grid-area: legend;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 0.25rem;
height: 0.25rem;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
box-shadow: inset 0 0 6px var(--text-muted);
}
@-moz-document url-prefix('') {
scrollbar-width: thin;
scrollbar-color: var(--text-muted);
}
}
.legend-list-item {
word-break: break-all;
}
.alert-overlay {
position: absolute;
width: 100%;
height: 100%;
z-index: 1;
}

View File

@ -0,0 +1,102 @@
import React from 'react'
import { ParentSize } from '@visx/responsive'
import classNames from 'classnames'
import useResizeObserver from 'use-resize-observer'
import { useDebounce } from '@sourcegraph/wildcard'
import { getLineColor, LegendItem, LegendList } from '../../../../../../../../charts'
import { ScrollBox } from '../../../../../../../../views/components/view/content/chart-view-content/charts/line/components/scroll-box/ScrollBox'
import { BackendInsightData } from '../../../../../../core'
import { SeriesBasedChartTypes, SeriesChart } from '../../../../../views'
import { BackendAlertOverlay } from '../backend-insight-alerts/BackendInsightAlerts'
import styles from './BackendInsightChart.module.scss'
/**
* If width of the chart is less than this var width value we should put the legend
* block below the chart block
*
* ```
* Less than 450px - put legend below Chart block has enough space - render legend aside
*
* Item 1
* Item 2
*
*
*
*
*
* Item 1 Item 2
* ```
*/
export const MINIMAL_HORIZONTAL_LAYOUT_WIDTH = 460
/**
* Even if you have a big enough width for putting legend aside (see {@link MINIMAL_HORIZONTAL_LAYOUT_WIDTH})
* we should enable this mode only if line chart has more than N series
*/
export const MINIMAL_SERIES_FOR_ASIDE_LEGEND = 3
interface BackendInsightChartProps<Datum> extends BackendInsightData {
locked: boolean
className?: string
onDatumClick: () => void
}
export function BackendInsightChart<Datum>(props: BackendInsightChartProps<Datum>): React.ReactElement {
const { locked, isFetchingHistoricalData, content, className, onDatumClick } = props
const { ref, width = 0 } = useDebounce(useResizeObserver(), 100)
const hasViewManySeries = content.series.length > MINIMAL_SERIES_FOR_ASIDE_LEGEND
const hasEnoughXSpace = width >= MINIMAL_HORIZONTAL_LAYOUT_WIDTH
const isHorizontalMode = hasViewManySeries && hasEnoughXSpace
return (
<div ref={ref} className={classNames(className, styles.chart, { [styles.chartHorizontal]: isHorizontalMode })}>
{width && (
<>
<ParentSize
debounceTime={0}
enableDebounceLeadingCall={true}
className={styles.responsiveContainer}
>
{parent => (
<>
<BackendAlertOverlay
hasNoData={content.data.length === 0}
isFetchingHistoricalData={isFetchingHistoricalData}
className={styles.alertOverlay}
/>
<SeriesChart
type={SeriesBasedChartTypes.Line}
width={parent.width}
height={parent.height}
locked={locked}
onDatumClick={onDatumClick}
{...content}
/>
</>
)}
</ParentSize>
<ScrollBox className={styles.legendListContainer}>
<LegendList className={styles.legendList}>
{content.series.map(series => (
<LegendItem
key={series.dataKey as string}
color={getLineColor(series)}
name={series.name}
className={styles.legendListItem}
/>
))}
</LegendList>
</ScrollBox>
</>
)}
</div>
)
}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { InsightFilters } from '../../../../../../core/types'
import { InsightFilters } from '../../../../../../core'
import { FormChangeEvent, SubmissionResult } from '../../../../../form/hooks/useForm'
import {

View File

@ -5,15 +5,15 @@ import FilterOutlineIcon from 'mdi-react/FilterOutlineIcon'
import { Button, Popover, PopoverContent, PopoverTrigger, Position } from '@sourcegraph/wildcard'
import { InsightFilters } from '../../../../../../core/types'
import { InsightFilters } from '../../../../../../core'
import { SubmissionResult } from '../../../../../form/hooks/useForm'
import { hasActiveFilters } from '../drill-down-filters-panel/components/drill-down-filters-form/DrillDownFiltersForm'
import { DrillDownInsightCreationFormValues } from '../drill-down-filters-panel/components/drill-down-insight-creation-form/DrillDownInsightCreationForm'
import { DrillDownFiltersPanel } from '../drill-down-filters-panel/DrillDownFiltersPanel'
import styles from './DrillDownFiltersPanel.module.scss'
import styles from './DrillDownFiltersPopover.module.scss'
interface DrillDownFiltersProps {
interface DrillDownFiltersPopoverProps {
isOpen: boolean
initialFiltersValue: InsightFilters
originalFiltersValue: InsightFilters
@ -28,7 +28,7 @@ interface DrillDownFiltersProps {
// the filter panel should not trigger react-grid-layout events.
const handleMouseDown: DOMAttributes<HTMLElement>['onMouseDown'] = event => event.stopPropagation()
export const DrillDownFiltersAction: React.FunctionComponent<DrillDownFiltersProps> = props => {
export const DrillDownFiltersPopover: React.FunctionComponent<DrillDownFiltersPopoverProps> = props => {
const {
isOpen,
popoverTargetRef,

View File

@ -0,0 +1,8 @@
export type { DrillDownInsightCreationFormValues } from './drill-down-filters-panel/components/drill-down-insight-creation-form/DrillDownInsightCreationForm'
export { DrillDownFiltersPanel } from './drill-down-filters-panel/DrillDownFiltersPanel'
export { DrillDownFiltersPopover } from './drill-down-filters-popover/DrillDownFiltersPopover'
export { EMPTY_DRILLDOWN_FILTERS } from './drill-down-filters-panel/utils'
export { BackendInsightErrorAlert, BackendAlertOverlay } from './backend-insight-alerts/BackendInsightAlerts'
export { BackendInsightChart } from './backend-insight-chart/BackendInsightChart'

View File

@ -1,106 +0,0 @@
import React, { Ref, useContext, useMemo, useRef, useState } from 'react'
import { useMergeRefs } from 'use-callback-ref'
import { isErrorLike } from '@sourcegraph/common'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import * as View from '../../../../../../views'
import { LineChartSettingsContext } from '../../../../../../views'
import { CodeInsightsBackendContext, LangStatsInsight, SearchRuntimeBasedInsight } from '../../../../core'
import { useDeleteInsight } from '../../../../hooks/use-delete-insight'
import { useDistinctValue } from '../../../../hooks/use-distinct-value'
import { useRemoveInsightFromDashboard } from '../../../../hooks/use-remove-insight'
import { DashboardInsightsContext } from '../../../../pages/dashboards/dashboard-page/components/dashboards-content/components/dashboard-inisghts/DashboardInsightsContext'
import { useCodeInsightViewPings, getTrackingTypeByInsightType } from '../../../../pings'
import { useInsightData } from '../../hooks/use-insight-data'
import { InsightContextMenu } from '../insight-context-menu/InsightContextMenu'
interface BuiltInInsightProps extends TelemetryProps, React.HTMLAttributes<HTMLElement> {
insight: SearchRuntimeBasedInsight | LangStatsInsight
innerRef: Ref<HTMLElement>
resizing: boolean
}
/**
* Historically we had a few insights that were worked via extension API
* search-based, code-stats insight
*
* This component renders insight card that works almost like before with extensions
* Component sends FE network request to get and process information but does that in
* main work thread instead of using Extension API.
*/
export function BuiltInInsight(props: BuiltInInsightProps): React.ReactElement {
const { insight, resizing, telemetryService, innerRef, ...otherProps } = props
const { getBuiltInInsightData } = useContext(CodeInsightsBackendContext)
const { dashboard } = useContext(DashboardInsightsContext)
const insightCardReference = useRef<HTMLDivElement>(null)
const mergedInsightCardReference = useMergeRefs([insightCardReference, innerRef])
const cachedInsight = useDistinctValue(insight)
const { data, loading, isVisible } = useInsightData(
useMemo(() => () => getBuiltInInsightData({ insight: cachedInsight }), [getBuiltInInsightData, cachedInsight]),
insightCardReference
)
// Visual line chart settings
const [zeroYAxisMin, setZeroYAxisMin] = useState(false)
const { delete: handleDelete, loading: isDeleting } = useDeleteInsight()
const { remove: handleRemove, loading: isRemoving } = useRemoveInsightFromDashboard()
const { trackDatumClicks, trackMouseLeave, trackMouseEnter } = useCodeInsightViewPings({
telemetryService,
insightType: getTrackingTypeByInsightType(insight.type),
})
return (
<View.Root
{...otherProps}
innerRef={mergedInsightCardReference}
title={insight.title}
actions={
isVisible && (
<InsightContextMenu
insight={insight}
dashboard={dashboard}
menuButtonClassName="ml-1 d-inline-flex"
zeroYAxisMin={zeroYAxisMin}
onToggleZeroYAxisMin={() => setZeroYAxisMin(!zeroYAxisMin)}
onRemoveFromDashboard={dashboard => handleRemove({ insight, dashboard })}
onDelete={() => handleDelete(insight)}
/>
)
}
data-testid={`insight-card.${insight.id}`}
onMouseEnter={trackMouseEnter}
onMouseLeave={trackMouseLeave}
>
{resizing ? (
<View.Banner>Resizing</View.Banner>
) : !data || loading || isDeleting || !isVisible ? (
<View.LoadingContent text={isDeleting ? 'Deleting code insight' : 'Loading code insight'} />
) : isRemoving ? (
<View.LoadingContent text="Removing insight from the dashboard" />
) : isErrorLike(data.view) ? (
<View.ErrorContent error={data.view} title={insight.id} />
) : (
data.view && (
<LineChartSettingsContext.Provider value={{ zeroYAxisMin }}>
<View.Content
content={data.view.content}
onDatumLinkClick={trackDatumClicks}
locked={insight.isFrozen}
/>
</LineChartSettingsContext.Provider>
)
)}
{
// Passing children props explicitly to render any top-level content like
// resize-handler from the react-grid-layout library
isVisible && otherProps.children
}
</View.Root>
)
}

View File

@ -6,7 +6,7 @@ import DotsVerticalIcon from 'mdi-react/DotsVerticalIcon'
import { Link, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList, Position } from '@sourcegraph/wildcard'
import { Insight, InsightDashboard, isVirtualDashboard } from '../../../../core/types'
import { Insight, InsightDashboard, isVirtualDashboard } from '../../../../core'
import { useUiFeatures } from '../../../../hooks/use-ui-features'
import styles from './InsightContextMenu.module.scss'

View File

@ -1,19 +1,14 @@
import { useContext, useEffect, useState } from 'react'
import { RefObject, useContext, useEffect, useState } from 'react'
import { ObservableInput } from 'rxjs'
import { ErrorLike } from '@sourcegraph/common'
import { CodeInsightsBackendContext } from '../../../core/backend/code-insights-backend-context'
import { CodeInsightsGqlBackend } from '../../../core/backend/gql-backend/code-insights-gql-backend'
import { useLazyParallelRequest } from '../../../hooks/use-parallel-requests/use-parallel-request'
import { CodeInsightsBackendContext, CodeInsightsGqlBackend } from '../../../core'
import { LazyQueryState, useLazyParallelRequest } from '../../../hooks/use-parallel-requests/use-parallel-request'
export interface UseInsightDataResult<T> {
data: T | undefined
error: ErrorLike | undefined
loading: boolean
isVisible: boolean
query: (request: () => ObservableInput<T>) => void
state: LazyQueryState<T>
}
/**
@ -26,15 +21,15 @@ export interface UseInsightDataResult<T> {
*/
export function useInsightData<D>(
request: () => ObservableInput<D>,
reference: React.RefObject<HTMLElement>
reference: RefObject<HTMLElement>
): UseInsightDataResult<D> {
const api = useContext(CodeInsightsBackendContext)
const isGqlAPI = api instanceof CodeInsightsGqlBackend
const { data, loading, error, query } = useLazyParallelRequest<D>()
const { state, query } = useLazyParallelRequest<D>()
// All non GQL API implementations do not support partial loading,
// allowing insights fetching for these API whether insights are
// allowing insights fetching for this API whether insights are
// in a viewport or not.
const [isVisible, setVisibility] = useState<boolean>(!isGqlAPI)
const [hasIntersected, setHasIntersected] = useState<boolean>(!isGqlAPI)
@ -77,5 +72,5 @@ export function useInsightData<D>(
return () => observer.unobserve(element)
}, [isGqlAPI, reference])
return { data, loading, error, isVisible, query }
return { state, isVisible, query }
}

View File

@ -1,23 +0,0 @@
import React from 'react'
import { useLocation } from 'react-router-dom'
import { Link, LinkProps } from '@sourcegraph/wildcard'
export interface LinkWithQueryProps extends Omit<LinkProps, 'to'> {
to: string
}
/**
* Renders react router link component with query params preserving between route transitions.
*/
export const LinkWithQuery: React.FunctionComponent<LinkWithQueryProps> = props => {
const { children, to, ...otherProps } = props
const { search } = useLocation()
return (
<Link to={to + search} {...otherProps}>
{children}
</Link>
)
}

View File

@ -5,6 +5,7 @@ import { useLocation } from 'react-router-dom'
import { Card, ForwardReferenceComponent, LoadingSpinner } from '@sourcegraph/wildcard'
import { getLineColor, LegendItem, LegendList, Series } from '../../../../../charts'
import { ErrorBoundary } from '../../../../../components/ErrorBoundary'
import styles from './InsightCard.module.scss'
@ -75,19 +76,38 @@ const InsightCardBanner: React.FunctionComponent<HTMLAttributes<HTMLDivElement>>
</div>
)
interface InsightCardLegendProps extends React.HTMLAttributes<HTMLUListElement> {
series: Series<any>[]
}
const InsightCardLegend: React.FunctionComponent<InsightCardLegendProps> = props => {
const { series, ...attributes } = props
return (
<LegendList {...attributes}>
{series.map(series => (
<LegendItem key={series.dataKey as string} color={getLineColor(series)} name={series.name} />
))}
</LegendList>
)
}
const Root = InsightCard
const Header = InsightCardHeader
const Loading = InsightCardLoading
const Banner = InsightCardBanner
const Legends = InsightCardLegend
export {
InsightCard,
InsightCardHeader,
InsightCardLoading,
InsightCardBanner,
InsightCardLegend,
// * as Card imports
Root,
Header,
Loading,
Banner,
Legends,
}

View File

@ -2,6 +2,7 @@ import React, { SVGProps } from 'react'
import { CategoricalLikeChart, PieChart } from '../../../../../../charts'
import { CategoricalBasedChartTypes } from '../../types'
import { LockedChart } from '../locked/LockedChart'
export interface CategoricalChartProps<Datum>
extends CategoricalLikeChart<Datum>,
@ -9,10 +10,15 @@ export interface CategoricalChartProps<Datum>
type: CategoricalBasedChartTypes
width: number
height: number
locked?: boolean
}
export function CategoricalChart<Datum>(props: CategoricalChartProps<Datum>): React.ReactElement | null {
const { type, ...otherProps } = props
const { type, locked, ...otherProps } = props
if (locked) {
return <LockedChart />
}
if (type === CategoricalBasedChartTypes.Pie) {
return <PieChart {...otherProps} />

View File

@ -1,2 +1,5 @@
export { SeriesChart } from './series/SeriesChart'
export { CategoricalChart } from './categorical/CategoricalChart'
export type { SeriesChartProps } from './series/SeriesChart'
export type { CategoricalChartProps } from './categorical/CategoricalChart'

View File

@ -0,0 +1,30 @@
.wrapper {
display: flex;
flex-direction: column;
height: 100%;
margin: 0 2rem;
border-top: solid 1px var(--border-color);
border-bottom: solid 1px var(--border-color);
justify-content: center;
align-items: center;
color: var(--icon-color);
}
.banner {
display: flex;
justify-content: center;
align-items: center;
margin-top: 1.25rem;
}
.banner span {
color: var(--purple-03);
text-transform: uppercase;
padding-right: 0.5rem;
margin-right: 0.5rem;
border-right: solid 1px var(--purple-01);
}
.banner small {
color: var(--text-muted);
}

View File

@ -0,0 +1,16 @@
import React from 'react'
import classNames from 'classnames'
import LockIcon from 'mdi-react/LockOutlineIcon'
import styles from './LockedChart.module.scss'
export const LockedChart: React.FunctionComponent<{ className?: string }> = ({ className }) => (
<section className={classNames(styles.wrapper, className)}>
<LockIcon size={40} />
<div className={classNames(styles.banner)}>
<span>Limited access</span>
<small>Insight locked</small>
</div>
</section>
)

View File

@ -2,15 +2,22 @@ import React, { SVGProps } from 'react'
import { LineChart, SeriesLikeChart } from '../../../../../../charts'
import { SeriesBasedChartTypes } from '../../types'
import { LockedChart } from '../locked/LockedChart'
export interface SeriesChartProps<D> extends SeriesLikeChart<D>, Omit<SVGProps<SVGSVGElement>, 'type'> {
type: SeriesBasedChartTypes
width: number
height: number
zeroYAxisMin?: boolean
locked?: boolean
}
export function SeriesChart<Datum>(props: SeriesChartProps<Datum>): React.ReactElement {
const { type, ...otherProps } = props
const { type, locked, ...otherProps } = props
if (locked) {
return <LockedChart />
}
return <LineChart {...otherProps} />
}

View File

@ -1,4 +1,6 @@
export { InsightCard, InsightCardHeader, InsightCardBanner, InsightCardLoading } from './card/InsightCard'
export * from './card/InsightCard'
export { SeriesChart, CategoricalChart } from './chart'
export { CategoricalBasedChartTypes, SeriesBasedChartTypes } from './types'
export type { SeriesChartProps, CategoricalChartProps } from './chart'

View File

@ -1,5 +1,4 @@
import { Duration } from 'date-fns'
import { LineChartContent as LegacyLineChartContent } from 'sourcegraph'
import { Series } from '../../../../charts'
import {
@ -12,6 +11,7 @@ import {
SearchBackendBasedInsight,
SearchRuntimeBasedInsight,
} from '../types'
import { InsightContentType } from '../types/insight/common'
export interface CategoricalChartContent<Datum> {
data: Datum[]
@ -27,6 +27,18 @@ export interface SeriesChartContent<Datum> {
getXValue: (datum: Datum) => Date
}
export interface InsightCategoricalContent<Datum> {
type: InsightContentType.Categorical
content: CategoricalChartContent<Datum>
}
export interface InsightSeriesContent<Datum> {
type: InsightContentType.Series
content: SeriesChartContent<Datum>
}
export type InsightContent<Datum> = InsightSeriesContent<Datum> | InsightCategoricalContent<Datum>
export interface DashboardCreateInput {
name: string
owners: InsightsDashboardOwner[]
@ -103,14 +115,14 @@ export interface AccessibleInsightInfo {
title: string
}
export interface BackendInsightDatum {
dateTime: number
[seriesKey: string]: number | string
}
export interface BackendInsightData {
id: string
view: {
title: string
subtitle?: string
content: LegacyLineChartContent<any, string>[]
isFetchingHistoricalData: boolean
}
content: SeriesChartContent<any>
isFetchingHistoricalData: boolean
}
export interface GetBuiltInsightInput {

View File

@ -1,7 +1,5 @@
import { Observable } from 'rxjs'
import { ViewProviderResult } from '@sourcegraph/shared/src/api/extension/extensionHostApi'
import { BackendInsight, Insight, InsightDashboard, InsightsDashboardOwner } from '../types'
import {
@ -25,6 +23,7 @@ import {
CategoricalChartContent,
SeriesChartContent,
UiFeaturesConfig,
InsightContent,
} from './code-insights-backend-types'
/**
@ -96,7 +95,7 @@ export interface CodeInsightsBackend {
* Returns extension like built-in insight that is fetched via frontend
* network utils to Sourcegraph search API.
*/
getBuiltInInsightData: (input: GetBuiltInsightInput) => Observable<ViewProviderResult>
getBuiltInInsightData: (input: GetBuiltInsightInput) => Observable<InsightContent<unknown>>
/**
* Returns content for the search based insight live preview chart.

View File

@ -230,53 +230,14 @@ export class CodeInsightsGqlBackend implements CodeInsightsBackend {
// Live preview fetchers
public getSearchInsightContent = (input: GetSearchInsightContentInput): Promise<SeriesChartContent<any>> =>
getSearchInsightContent(input).then(data => {
const { data: datumList, series, xAxis } = data
// TODO: Remove this when the dashboard page has new chart fetchers
return {
data: datumList,
series: series.map(series => ({
dataKey: series.dataKey,
name: series.name ?? '',
color: series.stroke,
getLinkURL: datum => series.linkURLs?.[+datum[xAxis.dataKey]] ?? undefined,
})),
getXValue: datum => new Date(+datum[xAxis.dataKey]),
}
})
getSearchInsightContent(input).then(data => data.content)
public getLangStatsInsightContent = (
input: GetLangStatsInsightContentInput
): Promise<CategoricalChartContent<any>> =>
getLangStatsInsightContent(input).then(data => {
const { data: dataList, dataKey, nameKey, fillKey = '', linkURLKey = '' } = data.pies[0]
// TODO: Remove this when the dashboard page has new chart fetchers
return {
data: dataList,
getDatumValue: datum => datum[dataKey],
getDatumColor: datum => datum[fillKey ?? ''],
getDatumName: datum => datum[nameKey],
getDatumLink: datum => datum[linkURLKey],
}
})
): Promise<CategoricalChartContent<any>> => getLangStatsInsightContent(input).then(data => data.content)
public getCaptureInsightContent = (input: CaptureInsightSettings): Promise<SeriesChartContent<any>> =>
getCaptureGroupInsightsPreview(this.apolloClient, input).then(data => {
const { data: datumList, series, xAxis } = data
// TODO: Remove this when the dashboard page has new chart fetchers
return {
data: datumList,
series: series.map(series => ({
dataKey: series.dataKey,
name: series.name ?? '',
color: series.stroke,
})),
getXValue: datum => new Date(+datum[xAxis.dataKey]),
}
})
getCaptureGroupInsightsPreview(this.apolloClient, input)
// Repositories API
public getRepositorySuggestions = getRepositorySuggestions

View File

@ -12,14 +12,10 @@ export const createBackendInsightData = (insight: BackendInsight, response: Insi
const seriesMetadata = getParsedDataSeriesMetadata(insight, seriesData)
return {
id: insight.id,
view: {
title: insight.title,
content: [createLineChartContent(seriesData, seriesMetadata, insight.filters)],
isFetchingHistoricalData: seriesData.some(
({ status: { pendingJobs, backfillQueuedAt } }) => pendingJobs > 0 || backfillQueuedAt === null
),
},
content: createLineChartContent(seriesData, seriesMetadata, insight.filters),
isFetchingHistoricalData: seriesData.some(
({ status: { pendingJobs, backfillQueuedAt } }) => pendingJobs > 0 || backfillQueuedAt === null
),
}
}

View File

@ -1,35 +1,19 @@
import { Observable, of } from 'rxjs'
import { catchError, map, switchMap } from 'rxjs/operators'
import { asError } from '@sourcegraph/common'
import { ViewProviderResult } from '@sourcegraph/shared/src/api/extension/extensionHostApi'
import { switchMap } from 'rxjs/operators'
import { isSearchBasedInsight } from '../../../../types'
import { GetBuiltInsightInput } from '../../../code-insights-backend-types'
import { GetBuiltInsightInput, InsightContent } from '../../../code-insights-backend-types'
import { getLangStatsInsightContent } from './get-lang-stats-insight-content'
import { getSearchInsightContent } from './get-search-insight-content'
export function getBuiltInInsight(input: GetBuiltInsightInput): Observable<ViewProviderResult> {
export function getBuiltInInsight(input: GetBuiltInsightInput): Observable<InsightContent<any>> {
const { insight } = input
return of(insight).pipe(
// TODO Implement declarative fetchers map by insight type
switchMap(insight =>
isSearchBasedInsight(insight) ? getSearchInsightContent(insight) : getLangStatsInsightContent(insight)
),
map(data => ({
id: insight.id,
view: {
title: insight.title,
content: [data],
},
})),
catchError(error =>
of<ViewProviderResult>({
id: insight.id,
view: asError(error),
})
)
)
}

View File

@ -1,12 +1,19 @@
import { escapeRegExp, partition, sum } from 'lodash'
import { defer } from 'rxjs'
import { map, retry } from 'rxjs/operators'
import { PieChartContent } from 'sourcegraph'
import { GetLangStatsInsightContentInput } from '../../../code-insights-backend-types'
import { InsightContentType } from '../../../../types/insight/common'
import { GetLangStatsInsightContentInput, InsightCategoricalContent } from '../../../code-insights-backend-types'
import { fetchLangStatsInsight } from './utils/fetch-lang-stats-insight'
interface LangStatsDatum {
totalLines: number
name: string
linkURL: string
fill: string
}
const getLangColor = async (language: string): Promise<string> => {
const { default: languagesMap } = await import('linguist-languages')
@ -22,7 +29,7 @@ const getLangColor = async (language: string): Promise<string> => {
export async function getLangStatsInsightContent(
input: GetLangStatsInsightContentInput
): Promise<PieChartContent<any>> {
): Promise<InsightCategoricalContent<LangStatsDatum>> {
return getInsightContent({ ...input, repo: input.repository })
}
@ -31,7 +38,7 @@ interface GetInsightContentInputs extends GetLangStatsInsightContentInput {
path?: string
}
async function getInsightContent(inputs: GetInsightContentInputs): Promise<PieChartContent<any>> {
async function getInsightContent(inputs: GetInsightContentInputs): Promise<InsightCategoricalContent<LangStatsDatum>> {
const { otherThreshold, repo, path } = inputs
const pathRegexp = path ? `file:^${escapeRegExp(path)}/` : ''
@ -66,15 +73,13 @@ async function getInsightContent(inputs: GetInsightContentInputs): Promise<PieCh
)
return {
chart: 'pie' as const,
pies: [
{
data,
dataKey: 'totalLines',
nameKey: 'name',
fillKey: 'fill',
linkURLKey: 'linkURL',
},
],
type: InsightContentType.Categorical,
content: {
data,
getDatumColor: datum => datum.fill,
getDatumLink: datum => datum.linkURL,
getDatumName: datum => datum.name,
getDatumValue: datum => datum.totalLines,
},
}
}

View File

@ -1,11 +1,11 @@
import { formatISO, isAfter, startOfDay, sub, Duration } from 'date-fns'
import { Duration, formatISO, isAfter, startOfDay, sub } from 'date-fns'
import escapeRegExp from 'lodash/escapeRegExp'
import { defer } from 'rxjs'
import { retry } from 'rxjs/operators'
import type { LineChartContent } from 'sourcegraph'
import { EMPTY_DATA_POINT_VALUE } from '../../../../../../../views'
import { GetSearchInsightContentInput } from '../../../code-insights-backend-types'
import { InsightContentType } from '../../../../types/insight/common'
import { GetSearchInsightContentInput, InsightSeriesContent } from '../../../code-insights-backend-types'
import { fetchRawSearchInsightResults, fetchSearchInsightCommits } from './utils/fetch-search-insight'
import { queryHasCountFilter } from './utils/query-has-count-filter'
@ -23,7 +23,7 @@ interface InsightSeriesData {
export async function getSearchInsightContent(
input: GetSearchInsightContentInput
): Promise<LineChartContent<any, string>> {
): Promise<InsightSeriesContent<InsightSeriesData>> {
return getInsightContent({ ...input, repos: input.repositories })
}
@ -32,7 +32,9 @@ interface GetInsightContentInput extends GetSearchInsightContentInput {
path?: string
}
export async function getInsightContent(inputs: GetInsightContentInput): Promise<LineChartContent<any, string>> {
export async function getInsightContent(
inputs: GetInsightContentInput
): Promise<InsightSeriesContent<InsightSeriesData>> {
const { series, step, repos, path } = inputs
const stepInterval = step || { days: 1 }
const pathRegexp = path ? `^${escapeRegExp(path)}/` : undefined
@ -116,14 +118,16 @@ export async function getInsightContent(inputs: GetInsightContentInput): Promise
}
return {
chart: 'line' as const,
data,
series: series.map(series => ({
dataKey: series.name,
name: series.name,
stroke: series.stroke,
linkURLs: Object.fromEntries(
dates.map(date => {
type: InsightContentType.Series,
content: {
data,
getXValue: datum => new Date(datum.date),
series: series.map(series => ({
dataKey: series.name,
name: series.name,
color: series.stroke,
getLinkURL: datum => {
const date = datum.date
// Link to diff search that explains what new cases were added between two data points
const url = new URL('/search', window.location.origin)
// Use formatISO instead of toISOString(), because toISOString() always outputs UTC.
@ -136,14 +140,9 @@ export async function getInsightContent(inputs: GetInsightContentInput): Promise
url.searchParams.set('q', diffQuery)
return [date.getTime(), url.href]
})
),
})),
xAxis: {
dataKey: 'date' as const,
type: 'number' as const,
scale: 'time' as const,
return url.href
},
})),
},
}
}

View File

@ -1,21 +1,14 @@
import { ApolloClient, gql } from '@apollo/client'
import { startCase } from 'lodash'
import openColor from 'open-color'
import { LineChartContent } from 'sourcegraph'
import {
GetCaptureGroupInsightPreviewResult,
GetCaptureGroupInsightPreviewVariables,
} from '../../../../../../graphql-operations'
import { CaptureInsightSettings } from '../../code-insights-backend-types'
import { getDataPoints, InsightDataSeriesData } from '../../utils/create-line-chart-content'
import { CaptureInsightSettings, SeriesChartContent } from '../../code-insights-backend-types'
import { getDataPoints, getLinkKey, InsightDataSeriesData } from '../../utils/create-line-chart-content'
import { getStepInterval } from '../utils/get-step-interval'
import { MAX_NUMBER_OF_SERIES } from './get-backend-insight-data/deserializators'
const SERIES_COLORS = Object.keys(openColor)
.filter(name => name !== 'white' && name !== 'black' && name !== 'gray')
.map(name => ({ name: startCase(name), color: `var(--oc-${name}-7)` }))
import { DATA_SERIES_COLORS_LIST, MAX_NUMBER_OF_SERIES } from './get-backend-insight-data/deserializators'
const GET_CAPTURE_GROUP_INSIGHT_PREVIEW_GQL = gql`
query GetCaptureGroupInsightPreview($input: SearchInsightLivePreviewInput!) {
@ -28,11 +21,15 @@ const GET_CAPTURE_GROUP_INSIGHT_PREVIEW_GQL = gql`
}
}
`
export interface CaptureGroupInsightDatum {
dateTime: number
[seriesKey: string]: number | string
}
export const getCaptureGroupInsightsPreview = (
client: ApolloClient<unknown>,
input: CaptureInsightSettings
): Promise<LineChartContent<any, string>> => {
): Promise<SeriesChartContent<CaptureGroupInsightDatum>> => {
const [unit, value] = getStepInterval(input.step)
return client
@ -65,19 +62,33 @@ export const getCaptureGroupInsightsPreview = (
...series,
}))
// TODO Revisit live preview and dashboard insight resolver methods in order to
// improve series data handling and manipulation
const seriesMetadata = indexedSeries.map((generatedSeries, index) => ({
id: generatedSeries.seriesId,
name: generatedSeries.label,
query: input.query,
stroke: DATA_SERIES_COLORS_LIST[index % DATA_SERIES_COLORS_LIST.length],
}))
const seriesDefinitionMap = Object.fromEntries(
seriesMetadata.map(definition => [definition.id, definition])
)
return {
chart: 'line',
data: getDataPoints(indexedSeries),
series: indexedSeries.map((series, index) => ({
dataKey: series.seriesId,
name: series.label,
stroke: SERIES_COLORS[index % SERIES_COLORS.length].color,
data: getDataPoints({
series: indexedSeries,
seriesDefinitionMap,
includeRepoRegexp: '',
excludeRepoRegexp: '',
}),
series: indexedSeries.map((line, index) => ({
dataKey: line.seriesId,
name: line.label,
color: DATA_SERIES_COLORS_LIST[index % DATA_SERIES_COLORS_LIST.length],
getLinkURL: datum => `${datum[getLinkKey(line.seriesId)]}`,
})),
xAxis: {
dataKey: 'dateTime',
scale: 'time',
type: 'number',
},
getXValue: datum => new Date(datum.dateTime),
}
})
}

View File

@ -1,17 +1,15 @@
import { formatISO } from 'date-fns'
import { LineChartContent } from 'sourcegraph'
import { buildSearchURLQuery } from '@sourcegraph/shared/src/util/url'
import { Series } from '../../../../../charts'
import { InsightDataSeries, SearchPatternType } from '../../../../../graphql-operations'
import { semanticSort } from '../../../../../insights/utils/semantic-sort'
import { PageRoutes } from '../../../../../routes.constants'
import { InsightFilters, SearchBasedInsightSeries } from '../../types'
import { BackendInsightDatum, SeriesChartContent } from '../code-insights-backend-types'
interface SeriesDataset {
dateTime: number
[seriesKey: string]: number
}
type SeriesDefinition = Record<string, SearchBasedInsightSeries>
/**
* Minimal input type model for {@link createLineChartContent} function
@ -20,8 +18,7 @@ export type InsightDataSeriesData = Pick<InsightDataSeries, 'seriesId' | 'label'
/**
* Generates line chart content for visx chart. Note that this function relies on the fact that
* all series are indexed. This generator is used only for GQL api, only there we have indexed series
* for setting-based api see {@link createLineChartContent}
* all series are indexed.
*
* @param series - insight series with points data
* @param seriesDefinition - insight definition with line settings (color, name, query)
@ -31,71 +28,43 @@ export function createLineChartContent(
series: InsightDataSeriesData[],
seriesDefinition: SearchBasedInsightSeries[] = [],
filters?: InsightFilters
): LineChartContent<SeriesDataset, 'dateTime'> {
const definitionMap = Object.fromEntries<SearchBasedInsightSeries>(
): SeriesChartContent<BackendInsightDatum> {
const seriesDefinitionMap: SeriesDefinition = Object.fromEntries<SearchBasedInsightSeries>(
seriesDefinition.map(definition => [definition.id, definition])
)
const { includeRepoRegexp = '', excludeRepoRegexp = '' } = filters ?? {}
return {
chart: 'line',
data: getDataPoints(series),
data: getDataPoints({ series, seriesDefinitionMap, excludeRepoRegexp, includeRepoRegexp }),
series: series
.map(line => ({
name: definitionMap[line.seriesId]?.name ?? line.label,
.map<Series<BackendInsightDatum>>(line => ({
dataKey: line.seriesId,
stroke: definitionMap[line.seriesId]?.stroke,
linkURLs: Object.fromEntries(
[...line.points]
.sort((a, b) => Date.parse(a.dateTime) - Date.parse(b.dateTime))
.map((point, index, points) => {
const previousPoint = points[index - 1]
const date = Date.parse(point.dateTime)
// Use formatISO instead of toISOString(), because toISOString() always outputs UTC.
// They mark the same point in time, but using the user's timezone makes the date string
// easier to read (else the date component may be off by one day)
const after = previousPoint ? formatISO(Date.parse(previousPoint.dateTime)) : ''
const before = formatISO(date)
const includeRepoFilter = includeRepoRegexp ? `repo:${includeRepoRegexp}` : ''
const excludeRepoFilter = excludeRepoRegexp ? `-repo:${excludeRepoRegexp}` : ''
const repoFilter = `${includeRepoFilter} ${excludeRepoFilter}`
const afterFilter = after ? `after:${after}` : ''
const beforeFilter = `before:${before}`
const dateFilters = `${afterFilter} ${beforeFilter}`
const diffQuery = `${repoFilter} type:diff ${dateFilters} ${
definitionMap[line.seriesId].query
}`
const searchQueryParameter = buildSearchURLQuery(
diffQuery,
SearchPatternType.literal,
false
)
return [date, `${window.location.origin}${PageRoutes.Search}?${searchQueryParameter}`]
})
),
name: seriesDefinitionMap[line.seriesId]?.name ?? line.label,
color: seriesDefinitionMap[line.seriesId]?.stroke,
getLinkURL: datum => `${datum[getLinkKey(line.seriesId)]}`,
}))
.sort((a, b) => semanticSort(a.name, b.name)),
xAxis: {
dataKey: 'dateTime',
scale: 'time',
type: 'number',
},
getXValue: datum => new Date(datum.dateTime),
}
}
interface GetDataPointsInput {
series: InsightDataSeriesData[]
seriesDefinitionMap: SeriesDefinition
includeRepoRegexp: string
excludeRepoRegexp: string
}
/**
* Groups data series by dateTime (x axis) of each series
* Groups data series by dateTime (x-axis) of each series
*/
export function getDataPoints(series: InsightDataSeriesData[]): SeriesDataset[] {
const dataByXValue = new Map<string, SeriesDataset>()
export function getDataPoints(input: GetDataPointsInput): BackendInsightDatum[] {
const { series, seriesDefinitionMap, includeRepoRegexp, excludeRepoRegexp } = input
const dataByXValue = new Map<string, BackendInsightDatum>()
for (const line of series) {
for (const point of line.points) {
for (const [index, point] of line.points.entries()) {
let dataObject = dataByXValue.get(point.dateTime)
if (!dataObject) {
dataObject = {
@ -106,8 +75,51 @@ export function getDataPoints(series: InsightDataSeriesData[]): SeriesDataset[]
dataByXValue.set(point.dateTime, dataObject)
}
dataObject[line.seriesId] = point.value
dataObject[getLinkKey(line.seriesId)] = generateLinkURL({
previousPoint: line.points[index - 1],
series: seriesDefinitionMap[line.seriesId],
point,
includeRepoRegexp,
excludeRepoRegexp,
})
}
}
return [...dataByXValue.values()]
}
interface GenerateLinkInput {
series: SearchBasedInsightSeries
previousPoint?: { dateTime: string }
point: { dateTime: string }
includeRepoRegexp: string
excludeRepoRegexp: string
}
function generateLinkURL(input: GenerateLinkInput): string {
const { series, point, previousPoint, includeRepoRegexp, excludeRepoRegexp } = input
const date = Date.parse(point.dateTime)
// Use formatISO instead of toISOString(), because toISOString() always outputs UTC.
// They mark the same point in time, but using the user's timezone makes the date string
// easier to read (else the date component may be off by one day)
const after = previousPoint ? formatISO(Date.parse(previousPoint.dateTime)) : ''
const before = formatISO(date)
const includeRepoFilter = includeRepoRegexp ? `repo:${includeRepoRegexp}` : ''
const excludeRepoFilter = excludeRepoRegexp ? `-repo:${excludeRepoRegexp}` : ''
const repoFilter = `${includeRepoFilter} ${excludeRepoFilter}`
const afterFilter = after ? `after:${after}` : ''
const beforeFilter = `before:${before}`
const dateFilters = `${afterFilter} ${beforeFilter}`
const diffQuery = `${repoFilter} type:diff ${dateFilters} ${series.query}`
const searchQueryParameter = buildSearchURLQuery(diffQuery, SearchPatternType.literal, false)
return `${window.location.origin}${PageRoutes.Search}?${searchQueryParameter}`
}
export function getLinkKey(seriesId: string): string {
return `${seriesId}:link`
}

View File

@ -17,6 +17,11 @@ export enum InsightType {
CaptureGroup = 'CaptureGroup',
}
export enum InsightContentType {
Categorical,
Series,
}
export interface InsightFilters {
includeRepoRegexp: string
excludeRepoRegexp: string

View File

@ -3,8 +3,7 @@ import { useCallback, useContext, useState } from 'react'
import { ErrorLike } from '@sourcegraph/common'
import { eventLogger } from '../../../tracking/eventLogger'
import { CodeInsightsBackendContext } from '../core/backend/code-insights-backend-context'
import { Insight } from '../core/types'
import { CodeInsightsBackendContext, Insight } from '../core'
import { getTrackingTypeByInsightType } from '../pings'
type DeletionInsight = Pick<Insight, 'id' | 'title' | 'type'>

View File

@ -3,9 +3,7 @@ import { useEffect, useState } from 'react'
import { gql, useApolloClient } from '@apollo/client'
import { IsCodeInsightsLicensedResult } from '../../../graphql-operations'
import { CodeInsightsBackend } from '../core/backend/code-insights-backend'
import { CodeInsightsGqlBackend } from '../core/backend/gql-backend/code-insights-gql-backend'
import { CodeInsightsGqlBackendLimited } from '../core/backend/lam-backend/code-insights-gql-backend-limited'
import { CodeInsightsBackend, CodeInsightsGqlBackend, CodeInsightsGqlBackendLimited } from '../core'
/**
* Returns the full or limited version of the API based on

View File

@ -30,7 +30,23 @@ export interface FetchResult<T> {
loading: boolean
}
const MAX_PARALLEL_QUERIES = 2
export enum LazyQueryStatus {
Loading,
Data,
Error,
}
export type LazyQueryState<T> =
| { status: LazyQueryStatus.Loading }
| { status: LazyQueryStatus.Data; data: T }
| { status: LazyQueryStatus.Error; error: ErrorLike }
export interface LazyQueryResult<T> {
state: LazyQueryState<T>
query: (request: () => ObservableInput<T>) => Unsubscribable
}
const MAX_PARALLEL_QUERIES = 3
/**
* Parallel requests hooks factory. This factory/function generates special
@ -75,7 +91,7 @@ export function createUseParallelRequestsHook<T>({ maxRequests } = { maxRequests
takeUntil(cancel),
map(payload => ({ payload, onComplete })),
// In order to close observable and free up space for other queued requests
// in merge map queue. Consider to move this into consumers request calls
// in merge map queue. Consider moving this into consumers request calls
take(1),
catchError(error =>
of({
@ -144,12 +160,8 @@ export function createUseParallelRequestsHook<T>({ maxRequests } = { maxRequests
* This provides query methods that allows to you run your request in parallel with
* other request that have been made with useParallelRequests request calls.
*/
lazyQuery: <D>(): FetchResult<D> & { query: (request: () => ObservableInput<D>) => Unsubscribable } => {
const [state, setState] = useState<FetchResult<D>>({
data: undefined,
error: undefined,
loading: true,
})
lazyQuery: <D>(): LazyQueryResult<D> => {
const [state, setState] = useState<LazyQueryState<D>>({ status: LazyQueryStatus.Loading })
const localRequestPool = useRef<Request<D>[]>([])
@ -166,7 +178,7 @@ export function createUseParallelRequestsHook<T>({ maxRequests } = { maxRequests
const query = useCallback((request: () => ObservableInput<D>) => {
const cancelStream = new Subject<boolean>()
setState({ data: undefined, loading: true, error: undefined })
setState({ status: LazyQueryStatus.Loading })
const event: Request<D> = {
request,
@ -176,10 +188,10 @@ export function createUseParallelRequestsHook<T>({ maxRequests } = { maxRequests
localRequestPool.current = localRequestPool.current.filter(request => request !== event)
if (isErrorLike(result)) {
return setState({ data: undefined, loading: false, error: result })
return setState({ status: LazyQueryStatus.Error, error: result })
}
setState({ data: result, loading: false, error: undefined })
setState({ status: LazyQueryStatus.Data, data: result })
},
}
@ -199,7 +211,7 @@ export function createUseParallelRequestsHook<T>({ maxRequests } = { maxRequests
}
}, [])
return { ...state, query }
return { state, query }
},
}
}

View File

@ -3,8 +3,7 @@ import { useCallback, useContext, useState } from 'react'
import { ErrorLike } from '@sourcegraph/common'
import { eventLogger } from '../../../tracking/eventLogger'
import { CodeInsightsBackendContext } from '../core/backend/code-insights-backend-context'
import { Insight, InsightDashboard } from '../core/types'
import { CodeInsightsBackendContext, Insight, InsightDashboard } from '../core'
import { getTrackingTypeByInsightType } from '../pings'
interface RemoveInsightInput {

View File

@ -3,8 +3,7 @@ import { useContext, useMemo } from 'react'
import { Observable, of } from 'rxjs'
import { map } from 'rxjs/operators'
import { CodeInsightsBackendContext } from '../core/backend/code-insights-backend-context'
import { Insight, InsightDashboard, isSearchBasedInsight } from '../core/types'
import { CodeInsightsBackendContext, Insight, InsightDashboard, isSearchBasedInsight } from '../core'
import {
getDashboardPermissions,
getTooltipMessage,

View File

@ -1,4 +1,4 @@
import { InsightType } from '../core/types'
import { InsightType } from '../core'
export enum CodeInsightTrackType {
SearchBasedInsight = 'SearchBased',

View File

@ -67,7 +67,7 @@ describe('Code insights page', () => {
})
await driver.page.goto(driver.sourcegraphBaseUrl + '/insights/dashboards/all')
await driver.page.waitForSelector('[data-testid="line-chart__content"] svg circle')
await driver.page.waitForSelector('svg circle')
const variables = await testContext.waitForGraphQLRequest(async () => {
await driver.page.click('[data-testid="insight-card.001"] [data-testid="InsightContextMenuButton"]')

View File

@ -70,7 +70,7 @@ describe('Backend insight drill down filters', () => {
})
await driver.page.goto(driver.sourcegraphBaseUrl + '/insights/dashboards/all')
await driver.page.waitForSelector('[data-testid="line-chart__content"] svg circle')
await driver.page.waitForSelector('svg circle')
await driver.page.click('button[aria-label="Filters"]')
await driver.page.waitForSelector('[role="dialog"][aria-label="Drill-down filters panel"]')
@ -147,7 +147,7 @@ describe('Backend insight drill down filters', () => {
})
await driver.page.goto(driver.sourcegraphBaseUrl + '/insights/dashboards/all')
await driver.page.waitForSelector('[data-testid="line-chart__content"] svg circle')
await driver.page.waitForSelector('svg circle')
await driver.page.click('button[aria-label="Active filters"]')
await driver.page.waitForSelector('[role="dialog"][aria-label="Drill-down filters panel"]')

View File

@ -281,6 +281,8 @@ export function LineChartContent<Datum extends object>(props: LineChartContentPr
orientation="left"
tickValues={yTicks}
tickFormat={numberFormatter}
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
// @ts-ignore
tickLabelProps={getTickYProps}
tickComponent={Tick}
axisLineClassName={classNames(styles.axisLine, styles.axisLineVertical)}
@ -295,6 +297,8 @@ export function LineChartContent<Datum extends object>(props: LineChartContentPr
tickValues={xScale.ticks(numberOfTicksX)}
tickFormat={dateTickFormatter}
numTicks={numberOfTicksX}
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
// @ts-ignore
tickLabelProps={getTickXProps}
tickComponent={Tick}
tickLength={8}

View File

@ -3,7 +3,6 @@ import React from 'react'
import { TickLabelProps, TickRendererProps } from '@visx/axis/lib/types'
import { Group } from '@visx/group'
import { Text } from '@visx/text'
import { TextProps } from '@visx/text/lib/Text'
import { format } from 'd3-format'
import { timeFormat } from 'd3-time-format'
@ -22,6 +21,10 @@ export const dateTickFormatter = timeFormat('%d %b')
// Year + full name of month + full name of week day
export const dateLabelFormatter = timeFormat('%d %B %A')
interface TextProps {
'aria-label': string
}
// Label props generators for x and y axes.
// We need separate x and y generators because we need formatted value
// depend on for which axis we generate label props
@ -44,7 +47,7 @@ export const Tick: React.FunctionComponent<TickRendererProps> = props => {
return (
// eslint-disable-next-line jsx-a11y/aria-role
<Group role="text" aria-label={tickLabelProps['aria-label']}>
<Text aria-hidden={true} x={xPosition} y={yPosition} {...tickLabelProps}>
<Text aria-hidden={true} x={xPosition} y={yPosition} {...(tickLabelProps as TextProps)}>
{formattedValue}
</Text>
</Group>

View File

@ -374,12 +374,13 @@
"@types/svgo": "2.6.0",
"@types/vscode": "^1.63.1",
"@visx/annotation": "^2.9.0",
"@visx/axis": "^1.7.0",
"@visx/axis": "^2.6.0",
"@visx/glyph": "^1.7.0",
"@visx/grid": "^1.7.0",
"@visx/group": "^1.7.0",
"@visx/grid": "^2.6.0",
"@visx/group": "^2.1.0",
"@visx/mock-data": "^1.7.0",
"@visx/pattern": "^1.7.0",
"@visx/responsive": "^2.8.0",
"@visx/scale": "^1.7.0",
"@visx/xychart": "^1.7.3",
"@vscode/codicons": "^0.0.29",

View File

@ -2793,6 +2793,11 @@
resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796"
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
"@juggle/resize-observer@^3.3.1":
version "3.3.1"
resolved "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
"@lezer/common@^0.15.0", "@lezer/common@^0.15.5":
version "0.15.11"
resolved "https://registry.npmjs.org/@lezer/common/-/common-0.15.11.tgz#965b5067036305f12e8a3efc344076850be1d3a8"
@ -6295,7 +6300,7 @@
prop-types "^15.5.10"
react-use-measure "^2.0.4"
"@visx/axis@1.7.0", "@visx/axis@^1.7.0":
"@visx/axis@1.7.0":
version "1.7.0"
resolved "https://registry.npmjs.org/@visx/axis/-/axis-1.7.0.tgz#ae3bf46bb508eca9cd61cd7dcf6a32a1facdeeef"
integrity sha512-C9XCszH+lMyA73lMalrdloDFI3P8E301KZqfmybU+OpwLtClqsEQmzWWpRmw5xPElgtSLztemsx8SczHFGLKuw==
@ -6310,6 +6315,20 @@
classnames "^2.2.5"
prop-types "^15.6.0"
"@visx/axis@^2.6.0":
version "2.6.0"
resolved "https://registry.npmjs.org/@visx/axis/-/axis-2.6.0.tgz#33c7713590818705ae4a10bc6ab6d589bdb9ce9c"
integrity sha512-Ti8AxclK4m/2SHQPGMMsvotTxZGaLQIMB10C9tyHP9JNBCLWKWtq1XTrGxlySffnfhXML80zLYsEdLPumzxFvw==
dependencies:
"@types/react" "*"
"@visx/group" "2.1.0"
"@visx/point" "2.6.0"
"@visx/scale" "2.2.2"
"@visx/shape" "2.4.0"
"@visx/text" "2.3.0"
classnames "^2.3.1"
prop-types "^15.6.0"
"@visx/bounds@1.7.0":
version "1.7.0"
resolved "https://registry.npmjs.org/@visx/bounds/-/bounds-1.7.0.tgz#cc32aaa5aa8711771b93ec4149ff087225dc0684"
@ -6383,7 +6402,7 @@
d3-shape "^1.2.0"
prop-types "^15.6.2"
"@visx/grid@1.7.0", "@visx/grid@^1.7.0":
"@visx/grid@1.7.0":
version "1.7.0"
resolved "https://registry.npmjs.org/@visx/grid/-/grid-1.7.0.tgz#b447b09d9b409a5b41ca7e04707e08d015c18dba"
integrity sha512-mJQjg67JogLNh5ta8RmOgilX0TPiEJWZ0O0VggFS2onThj1Ild0xZgZxCiGEFLqM8vpfuIQxb4qR2vTe16uPQQ==
@ -6398,7 +6417,21 @@
classnames "^2.2.5"
prop-types "^15.6.2"
"@visx/group@1.7.0", "@visx/group@^1.7.0":
"@visx/grid@^2.6.0":
version "2.6.0"
resolved "https://registry.npmjs.org/@visx/grid/-/grid-2.6.0.tgz#bb24e88dafd3eba3a867a4327dc9dad32bfda2be"
integrity sha512-LH4yioffUZRT1ic3KSYqhimF6GPZCs3jzv4Xdz5gNVsSbEY4BOQyulxtq8375Z80DpLwK1VQAMxoNdj1EDkxIg==
dependencies:
"@types/react" "*"
"@visx/curve" "2.1.0"
"@visx/group" "2.1.0"
"@visx/point" "2.6.0"
"@visx/scale" "2.2.2"
"@visx/shape" "2.4.0"
classnames "^2.3.1"
prop-types "^15.6.2"
"@visx/group@1.7.0":
version "1.7.0"
resolved "https://registry.npmjs.org/@visx/group/-/group-1.7.0.tgz#e0ef2efbe00ef05326215d65b3d8a2b114df4f35"
integrity sha512-rzSXtV0+MHUyK+rwhVSV4qaHdzGi3Me3PRFXJSIAKVfoJIZczOkudUOLy34WvSrRlVyoFvGL7k9U5g8wHyY3nw==
@ -6408,7 +6441,7 @@
classnames "^2.2.5"
prop-types "^15.6.2"
"@visx/group@2.1.0":
"@visx/group@2.1.0", "@visx/group@^2.1.0":
version "2.1.0"
resolved "https://registry.npmjs.org/@visx/group/-/group-2.1.0.tgz#65d4a72feb20dd09443f73cdc12ad5fb339792ef"
integrity sha512-bZKa54yVjGYPZZhzYHLz4AVlidSr4ET9B/xmSa7nnictMJWr7e/IuZThB/bMfDQlgdtvhcfTgs+ZluySc5SBUg==
@ -6469,6 +6502,17 @@
prop-types "^15.6.1"
resize-observer-polyfill "1.5.1"
"@visx/responsive@^2.8.0":
version "2.8.0"
resolved "https://registry.npmjs.org/@visx/responsive/-/responsive-2.8.0.tgz#96fac67b77e9573393be780d9657337bef9b72b2"
integrity sha512-/CDL6aWvQARwv1CkNh1bZ6QW7Q982UU3UVESZ5Sffp1s3toygvdCmuDreL8UpDjJp0PQNtoc2alee5VUSCxg3Q==
dependencies:
"@juggle/resize-observer" "^3.3.1"
"@types/lodash" "^4.14.172"
"@types/react" "*"
lodash "^4.17.21"
prop-types "^15.6.1"
"@visx/scale@1.7.0", "@visx/scale@^1.7.0":
version "1.7.0"
resolved "https://registry.npmjs.org/@visx/scale/-/scale-1.7.0.tgz#c46daade4492edb9eaec36fd3c87dc776960d6af"