Code Insights: Connect UI and compute insight, assemble creation UI page (#38762)

* Extract series sanitizer to a separate shared module

* Add compute insight FE model

* Update sanitisers re-export paths

* Support compute-powered insight in createInsight gql handler

* Adjust search insight with new series sanitizer function

* Add submit, clear and cancel actions to compute insight creation UI

* Integrate live preview and compute creation form

* Fix compute live preview legend rendering

* Hide colour pickers and sum up series values grouped by series names
This commit is contained in:
Vova Kulikov 2022-07-15 12:29:17 +03:00 committed by GitHub
parent 77a13fd4dc
commit 6c8e51cdde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 392 additions and 181 deletions

View File

@ -26,10 +26,23 @@ export interface FormSeriesProps {
* solution, see https://github.com/sourcegraph/sourcegraph/issues/38236
*/
queryFieldDescription?: ReactNode
/**
* This prop hides color picker from the series form. This field is needed for
* compute powered insight creation UI, see https://github.com/sourcegraph/sourcegraph/issues/38832
* for more details compute doesn't have series colors
*/
showColorPicker?: boolean
}
export const FormSeries: FC<FormSeriesProps> = props => {
const { seriesField, showValidationErrorsOnMount, repositories, queryFieldDescription } = props
const {
seriesField,
showValidationErrorsOnMount,
repositories,
queryFieldDescription,
showColorPicker = true,
} = props
const { licensed } = useUiFeatures()
const { series, changeSeries, editRequest, editCommit, cancelEdit, deleteSeries } = useEditableSeries(seriesField)
@ -47,6 +60,7 @@ export const FormSeries: FC<FormSeriesProps> = props => {
autofocus={line.autofocus}
repositories={repositories}
queryFieldDescription={queryFieldDescription}
showColorPicker={showColorPicker}
className={classNames('p-3', styles.formSeriesItem)}
onSubmit={editCommit}
onCancel={() => cancelEdit(line.id)}
@ -60,6 +74,7 @@ export const FormSeries: FC<FormSeriesProps> = props => {
onEdit={() => editRequest(line.id)}
onRemove={() => deleteSeries(line.id)}
className={styles.formSeriesItem}
showColor={showColorPicker}
{...line}
/>
)

View File

@ -36,6 +36,13 @@ interface FormSeriesInputProps {
*/
queryFieldDescription?: ReactNode
/**
* This prop hides color picker from the series form. This field is needed for
* compute powered insight creation UI, see https://github.com/sourcegraph/sourcegraph/issues/38832
* for more details whe compute doesn't have series colors
*/
showColorPicker: boolean
/** Enable autofocus behavior of the first input element of series form. */
autofocus?: boolean
@ -65,6 +72,7 @@ export const FormSeriesInput: FC<FormSeriesInputProps> = props => {
autofocus = true,
repositories,
queryFieldDescription,
showColorPicker,
onCancel = noop,
onSubmit = noop,
onChange = noop,
@ -148,13 +156,15 @@ export const FormSeriesInput: FC<FormSeriesInputProps> = props => {
{...getDefaultInputProps(queryField)}
/>
<FormColorInput
name={`color group of ${index} series`}
title="Color"
className="mt-4"
value={colorField.input.value}
onChange={colorField.input.onChange}
/>
{showColorPicker && (
<FormColorInput
name={`color group of ${index} series`}
title="Color"
className="mt-4"
value={colorField.input.value}
onChange={colorField.input.onChange}
/>
)}
<div className="mt-4">
<Button

View File

@ -17,6 +17,14 @@ interface SeriesCardProps {
query: string
/** Color value of series. */
stroke?: string
/**
* This prop hides color picker from the series form. This field is needed for
* compute powered insight creation UI, see https://github.com/sourcegraph/sourcegraph/issues/38832
* for more details whe compute doesn't have series colors
*/
showColor?: boolean
/** Custom class name for root button element. */
className?: string
/** Edit handler. */
@ -29,7 +37,16 @@ interface SeriesCardProps {
* Renders series card component, visual list item of series (name, color, query)
* */
export function SeriesCard(props: SeriesCardProps): ReactElement {
const { disabled, name, query, stroke: color = DEFAULT_DATA_SERIES_COLOR, className, onEdit, onRemove } = props
const {
disabled,
name,
query,
stroke: color = DEFAULT_DATA_SERIES_COLOR,
showColor = true,
className,
onEdit,
onRemove,
} = props
return (
<Card
@ -41,12 +58,14 @@ export function SeriesCard(props: SeriesCardProps): ReactElement {
>
<div className={styles.cardInfo}>
<div className={classNames('mb-1 ', styles.cardTitle)}>
<div
data-testid="series-color-mark"
/* eslint-disable-next-line react/forbid-dom-props */
style={{ color: disabled ? 'var(--icon-muted)' : color }}
className={styles.cardColorMark}
/>
{showColor && (
<div
data-testid="series-color-mark"
/* eslint-disable-next-line react/forbid-dom-props */
style={{ color: disabled ? 'var(--icon-muted)' : color }}
className={styles.cardColorMark}
/>
)}
<span
data-testid="series-name"
title={name}

View File

@ -1,7 +1,7 @@
export * from './live-preview'
export * from './creation-ui-layout/CreationUiLayout'
export { getSanitizedRepositories } from './sanitizers/repositories'
export { getSanitizedRepositories, getSanitizedSeries } from './sanitizers'
export { CodeInsightDashboardsVisibility } from './CodeInsightDashboardsVisibility'
export { CodeInsightTimeStepPicker } from './code-insight-time-step-picker/CodeInsightTimeStepPicker'

View File

@ -27,10 +27,9 @@ type State<D> =
| { status: StateStatus.Intact }
export function useLivePreview<D>(input: Input<D>): Output<D> {
// Synthetic deps to trigger dry run for fetching live preview data
const [lastPreviewVersion, setLastPreviewVersion] = useState(0)
const [state, setState] = useState<State<D>>({ status: StateStatus.Intact })
// Synthetic deps to trigger dry run for fetching live preview data
const debouncedInput = useDebounce(input, 500)
useEffect(() => {

View File

@ -1 +1,2 @@
export { getSanitizedRepositories } from './repositories'
export { getSanitizedSeries } from './series'

View File

@ -0,0 +1,18 @@
import { SearchBasedInsightSeries } from '../../../core'
export function getSanitizedLine(line: SearchBasedInsightSeries): SearchBasedInsightSeries {
return {
id: line.id,
name: line.name.trim(),
stroke: line.stroke,
// Query field is a reg exp field for code insight query setting
// Native html input element adds escape symbols by itself
// to prevent this behavior below we replace double escaping
// with just one series of escape characters e.g. - //
query: line.query.replace(/\\\\/g, '\\'),
}
}
export function getSanitizedSeries(rawSeries: SearchBasedInsightSeries[]): SearchBasedInsightSeries[] {
return rawSeries.map(getSanitizedLine)
}

View File

@ -5,7 +5,7 @@ import { useMergeRefs } from 'use-callback-ref'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { useSearchParameters } from '@sourcegraph/wildcard'
import { Insight, isBackendInsight } from '../../../core'
import { Insight, isBackendInsight, isComputeInsight } from '../../../core'
import { BackendInsightView } from './backend-insight/BackendInsight'
import { BuiltInInsight } from './built-in-insight/BuiltInInsight'
@ -46,6 +46,11 @@ export const SmartInsight = forwardRef<HTMLElement, SmartInsightProps>((props, r
)
}
if (isComputeInsight(insight)) {
// Compute-powered insight card isn't implemented yet
return null
}
// Search based extension and lang stats insight are handled by built-in fetchers
return (
<BuiltInInsight

View File

@ -10,6 +10,7 @@ import {
LangStatsInsight,
InsightsDashboardOwner,
SearchBasedInsight,
ComputeInsight,
} from '../types'
import { InsightContentType } from '../types/insight/common'
@ -71,14 +72,15 @@ export interface FindInsightByNameInput {
}
export type MinimalSearchBasedInsightData = Omit<SearchBasedInsight, 'id' | 'dashboardReferenceCount' | 'isFrozen'>
export type MinimalCaptureGroupInsightData = Omit<CaptureGroupInsight, 'id' | 'dashboardReferenceCount' | 'isFrozen'>
export type MinimalLangStatsInsightData = Omit<LangStatsInsight, 'id' | 'dashboardReferenceCount' | 'isFrozen'>
export type MinimalComputeInsightData = Omit<ComputeInsight, 'id' | 'dashboardReferenceCount' | 'isFrozen'>
export type CreationInsightInput =
| MinimalSearchBasedInsightData
| MinimalCaptureGroupInsightData
| MinimalLangStatsInsightData
| MinimalComputeInsightData
export interface InsightCreateInput {
insight: CreationInsightInput

View File

@ -15,6 +15,7 @@ import { InsightDashboard, InsightExecutionType, InsightType, isVirtualDashboard
import {
InsightCreateInput,
MinimalCaptureGroupInsightData,
MinimalComputeInsightData,
MinimalSearchBasedInsightData,
} from '../../../code-insights-backend-types'
import { createInsightView } from '../../deserialization/create-insight-view'
@ -32,6 +33,7 @@ export const createInsight = (apolloClient: ApolloClient<object>, input: Insight
switch (insight.type) {
case InsightType.CaptureGroup:
case InsightType.Compute:
case InsightType.SearchBased: {
return createSearchBasedInsight(apolloClient, insight, dashboard)
}
@ -55,7 +57,10 @@ export const createInsight = (apolloClient: ApolloClient<object>, input: Insight
}
}
type CreationSeriesInsightData = MinimalSearchBasedInsightData | MinimalCaptureGroupInsightData
type CreationSeriesInsightData =
| MinimalSearchBasedInsightData
| MinimalCaptureGroupInsightData
| MinimalComputeInsightData
function createSearchBasedInsight(
apolloClient: ApolloClient<object>,
@ -68,7 +73,7 @@ function createSearchBasedInsight(
// create the insight first and only after update this newly created insight with filter values
// This is due to lack of gql API flexibility and should be fixed as soon as BE gql API
// supports filters in the create insight mutation.
// TODO: Remove this imperative logic as soon as be supports filters
// TODO: Remove this imperative logic as soon as BE supports filters
if (insight.executionType === InsightExecutionType.Backend && insight.filters) {
return from(
apolloClient.mutate<FirstStepCreateSearchBasedInsightResult>({
@ -134,7 +139,7 @@ function createSearchBasedInsight(
}
/**
* Updates Apollo cache after insight creation. Add insight to main insights gql query,
* Updates Apollo caches after insight creation. Add insight to main insights gql query,
* add newly created insight to the cache dashboard that insight was crated from.
*/
function searchInsightCreationOptimisticUpdate(

View File

@ -2,12 +2,14 @@ import {
LineChartSearchInsightDataSeriesInput,
LineChartSearchInsightInput,
PieChartSearchInsightInput,
TimeIntervalStepUnit,
} from '../../../../../../../graphql-operations'
import { parseSeriesDisplayOptions } from '../../../../../components/insights-view-grid/components/backend-insight/components/drill-down-filters-panel/drill-down-filters/utils'
import { InsightDashboard, InsightType, isVirtualDashboard } from '../../../../types'
import {
CreationInsightInput,
MinimalCaptureGroupInsightData,
MinimalComputeInsightData,
MinimalLangStatsInsightData,
MinimalSearchBasedInsightData,
} from '../../../code-insights-backend-types'
@ -27,6 +29,8 @@ export function getInsightCreateGqlInput(
return getSearchInsightCreateInput(insight, dashboard)
case InsightType.CaptureGroup:
return getCaptureGroupInsightCreateInput(insight, dashboard)
case InsightType.Compute:
return getComputeInsightCreateInput(insight, dashboard)
case InsightType.LangStats:
return getLangStatsInsightCreateInput(insight, dashboard)
}
@ -115,3 +119,28 @@ export function getLangStatsInsightCreateInput(
return input
}
export function getComputeInsightCreateInput(
insight: MinimalComputeInsightData,
dashboard: InsightDashboard | null
): LineChartSearchInsightInput {
const input: LineChartSearchInsightInput = {
dataSeries: insight.series.map<LineChartSearchInsightDataSeriesInput>(series => ({
query: series.query,
options: {
label: series.name,
lineColor: series.stroke,
},
repositoryScope: { repositories: insight.repositories },
timeScope: { stepInterval: { unit: TimeIntervalStepUnit.WEEK, value: 2 } },
groupBy: insight.groupBy,
})),
options: { title: insight.title },
}
if (dashboard && !isVirtualDashboard(dashboard)) {
input.dashboards = [dashboard.id]
}
return input
}

View File

@ -1,7 +1,7 @@
import { ApolloClient } from '@apollo/client'
import { ApolloCache } from '@apollo/client/cache'
import { MutationUpdaterFunction } from '@apollo/client/core/types'
import { from, Observable } from 'rxjs'
import { from, Observable, throwError } from 'rxjs'
import {
UpdateLangStatsInsightResult,
@ -51,6 +51,10 @@ export const updateInsight = (
)
}
case InsightType.Compute: {
return throwError(new Error('update mutation for the compute-powered insight is not implemented yet'))
}
case InsightType.LangStats: {
return from(
client.mutate<UpdateLangStatsInsightResult, UpdateLangStatsInsightVariables>({

View File

@ -17,6 +17,7 @@ export enum InsightType {
SearchBased = 'SearchBased',
LangStats = 'LangStats',
CaptureGroup = 'CaptureGroup',
Compute = 'Compute',
}
export enum InsightContentType {
@ -43,6 +44,8 @@ export interface BaseInsight {
dashboards: InsightDashboardReference[]
isFrozen: boolean
// TODO: move these fields out of base insight since they are
// specific to the search based and capture group insights only
seriesDisplayOptions?: SeriesDisplayOptionsInput
appliedSeriesDisplayOptions?: SeriesDisplayOptions
defaultSeriesDisplayOptions?: SeriesDisplayOptions

View File

@ -1,5 +1,6 @@
import { InsightExecutionType, InsightType, InsightFilters, InsightDashboardReference } from './common'
import { CaptureGroupInsight } from './types/capture-group-insight'
import { ComputeInsight } from './types/compute-insight'
import { LangStatsInsight } from './types/lang-stat-insight'
import { SearchBasedInsight, SearchBasedInsightSeries } from './types/search-insight'
@ -11,6 +12,7 @@ export type {
SearchBasedInsightSeries,
LangStatsInsight,
CaptureGroupInsight,
ComputeInsight,
InsightFilters,
}
@ -18,7 +20,7 @@ export type {
* Main insight model. Union of all different insights by execution type (backend, runtime)
* and insight type (lang-stats, search based, capture group) insights.
*/
export type Insight = SearchBasedInsight | LangStatsInsight | CaptureGroupInsight
export type Insight = SearchBasedInsight | LangStatsInsight | CaptureGroupInsight | ComputeInsight
/**
* Backend insights - insights that have all data series points already in gql API.
@ -45,3 +47,7 @@ export function isCaptureGroupInsight(insight: Insight): insight is CaptureGroup
export function isLangStatsInsight(insight: Insight): insight is LangStatsInsight {
return insight.type === InsightType.LangStats
}
export function isComputeInsight(insight: Insight): insight is ComputeInsight {
return insight.type === InsightType.Compute
}

View File

@ -0,0 +1,14 @@
import { GroupByField } from '@sourcegraph/shared/src/graphql-operations'
import { BaseInsight, InsightExecutionType, InsightFilters, InsightType } from '../common'
import { SearchBasedInsightSeries } from './search-insight'
export interface ComputeInsight extends BaseInsight {
type: InsightType.Compute
repositories: string[]
filters: InsightFilters
series: SearchBasedInsightSeries[]
groupBy: GroupByField
executionType: InsightExecutionType.Backend
}

View File

@ -1,5 +1,7 @@
import { Meta, Story } from '@storybook/react'
import { GroupByField } from '@sourcegraph/shared/src/graphql-operations'
import { WebStory } from '../../../../../components/WebStory'
import { CodeInsightsBackendStoryMock } from '../../../CodeInsightsBackendStoryMock'
import { BackendInsightDatum, SeriesChartContent } from '../../../core'
@ -73,7 +75,12 @@ const codeInsightsBackend = {
export const ComputeLivePreview: Story = () => (
<CodeInsightsBackendStoryMock mocks={codeInsightsBackend}>
<div className="m-3 px-4 py-5 bg-white">
<ComputeLivePreviewComponent disabled={false} repositories="sourcegraph/sourcegraph" series={[]} />
<ComputeLivePreviewComponent
disabled={false}
repositories="sourcegraph/sourcegraph"
series={[]}
groupBy={GroupByField.AUTHOR}
/>
</div>
</CodeInsightsBackendStoryMock>
)

View File

@ -1,9 +1,11 @@
import React, { useContext, useMemo } from 'react'
import { ErrorAlert } from '@sourcegraph/branded/src/components/alerts'
import { useDeepMemo, Text } from '@sourcegraph/wildcard'
import { groupBy } from 'lodash'
import { Series } from '../../../../../charts'
import { ErrorAlert } from '@sourcegraph/branded/src/components/alerts'
import { useDeepMemo } from '@sourcegraph/wildcard'
import { LegendItem, LegendList, Series } from '../../../../../charts'
import { BarChart } from '../../../../../charts/components/bar-chart/BarChart'
import { GroupByField } from '../../../../../graphql-operations'
import {
@ -13,19 +15,23 @@ import {
LivePreviewChart,
LivePreviewBlurBackdrop,
LivePreviewBanner,
LivePreviewLegend,
getSanitizedRepositories,
useLivePreview,
StateStatus,
COMPUTE_MOCK_CHART,
EditableDataSeries,
} from '../../../components'
import { BackendInsightDatum, CategoricalChartContent, CodeInsightsBackendContext } from '../../../core'
import {
BackendInsightDatum,
CategoricalChartContent,
CodeInsightsBackendContext,
SeriesPreviewSettings,
} from '../../../core'
interface LanguageUsageDatum {
name: string
value: number
fill: string
linkURL: string
group?: string
}
@ -33,45 +39,38 @@ interface ComputeLivePreviewProps {
disabled: boolean
repositories: string
className?: string
series: {
query: string
label: string
stroke: string
groupBy?: GroupByField
}[]
groupBy: GroupByField
series: EditableDataSeries[]
}
export const ComputeLivePreview: React.FunctionComponent<ComputeLivePreviewProps> = props => {
// For the purposes of building out this component before the backend is ready
// we are using the standard "line series" type data.
// TODO after backend is merged, remove update the series value to use that structure
const { disabled, repositories, series, className } = props
const { getInsightPreviewContent: getLivePreviewContent } = useContext(CodeInsightsBackendContext)
const sanitizedSeries = series.map(srs => ({
query: srs.query,
label: srs.label,
stroke: srs.stroke,
groupBy: srs.groupBy,
}))
const { disabled, repositories, series, groupBy, className } = props
const { getInsightPreviewContent } = useContext(CodeInsightsBackendContext)
const settings = useDeepMemo({
disabled,
repositories: getSanitizedRepositories(repositories),
series: sanitizedSeries,
// For the purposes of building out this component before the backend is ready
// we are using the standard "line series" type data.
// TODO after backend is merged, remove update the series value to use that structure
series: series.map<SeriesPreviewSettings>(srs => ({
query: srs.query,
label: srs.name,
stroke: srs.stroke ?? 'blue',
generatedFromCaptureGroup: true,
groupBy,
})),
// TODO: Revisit this hardcoded value. Compute does not use it, but it's still required
// for `searchInsightPreview`
step: {
days: 1,
},
step: { days: 1 },
})
const getLivePreview = useMemo(
() => ({
disabled: settings.disabled,
fetcher: () => getLivePreviewContent(settings),
fetcher: () => getInsightPreviewContent(settings),
}),
[settings, getLivePreviewContent]
[settings, getInsightPreviewContent]
)
const { state, update } = useLivePreview(getLivePreview)
@ -117,22 +116,41 @@ export const ComputeLivePreview: React.FunctionComponent<ComputeLivePreviewProps
)}
{state.status === StateStatus.Data && (
<LivePreviewLegend series={state.data.series as Series<unknown>[]} />
<LegendList className="mt-3">
{state.data.series.map(series => (
<LegendItem
key={series.id}
color={getComputeSeriesColor(series)}
name={getComputeSeriesName(series)}
/>
))}
</LegendList>
)}
</LivePreviewCard>
<Text className="mt-4 pl-2">
<strong>Timeframe:</strong> May 20, 2022 - Oct 20, 2022
</Text>
</aside>
)
}
const mapSeriesToCompute = (series: Series<BackendInsightDatum>[]): LanguageUsageDatum[] =>
series.map(series => ({
group: series.name,
name: series.name,
value: series.data[0].value ?? 0,
fill: series.color ?? 'var(--blue)',
linkURL: series.data[0].link ?? '',
}))
const mapSeriesToCompute = (series: Series<BackendInsightDatum>[]): LanguageUsageDatum[] => {
const seriesGroups = groupBy(series, series => series.name)
// Group series result by seres name and sum up series value with the same name
return Object.keys(seriesGroups).map(key =>
seriesGroups[key].reduce(
(memo, series) => {
memo.value += series.data.reduce((sum, datum) => sum + (series.getYValue(datum) ?? 0), 0)
return memo
},
{
name: getComputeSeriesName(seriesGroups[key][0]),
fill: getComputeSeriesColor(seriesGroups[key][0]),
value: 0,
}
)
)
}
const getComputeSeriesName = (series: Series<any>): string => (series.name ? series.name : 'Other')
const getComputeSeriesColor = (series: Series<any>): string =>
series.name ? series.color ?? 'var(--blue)' : 'var(--oc-gray-4)'

View File

@ -70,7 +70,6 @@ export const CreationRoutes: React.FunctionComponent<React.PropsWithChildren<Cre
<Route
path={`${match.url}/group-results`}
exact={true}
render={() => (
<InsightCreationLazyPage
mode={InsightCreationPageType.Compute}

View File

@ -1,4 +1,4 @@
import React, { useContext, useMemo } from 'react'
import { FC, useContext, useMemo } from 'react'
import { useHistory } from 'react-router'
@ -28,9 +28,7 @@ interface InsightCreationPageProps extends TelemetryProps {
mode: InsightCreationPageType
}
export const InsightCreationPage: React.FunctionComponent<
React.PropsWithChildren<InsightCreationPageProps>
> = props => {
export const InsightCreationPage: FC<InsightCreationPageProps> = props => {
const { mode, telemetryService } = props
const history = useHistory()

View File

@ -1,19 +1,30 @@
import { FunctionComponent } from 'react'
import { FunctionComponent, useCallback, useMemo } from 'react'
import BarChartIcon from 'mdi-react/BarChartIcon'
import { asError } from '@sourcegraph/common'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Link, PageHeader, Text, useLocalStorage } from '@sourcegraph/wildcard'
import { Link, PageHeader, Text, useLocalStorage, useObservable } from '@sourcegraph/wildcard'
import { PageTitle } from '../../../../../../components/PageTitle'
import { CodeInsightsPage, FormChangeEvent } from '../../../../components'
import {
CodeInsightCreationMode,
CodeInsightsCreationActions,
CodeInsightsPage,
FORM_ERROR,
FormChangeEvent,
SubmissionErrors,
} from '../../../../components'
import { ComputeInsight } from '../../../../core'
import { useUiFeatures } from '../../../../hooks'
import { CodeInsightTrackType } from '../../../../pings'
import { ComputeInsightCreationContent } from './components/ComputeInsightCreationContent'
import { CreateComputeInsightFormFields } from './types'
import { getSanitizedComputeInsight } from './utils/insight-sanitaizer'
export interface InsightCreateEvent {
// TODO: It will be improved in https://github.com/sourcegraph/sourcegraph/issues/37965
insight: any
insight: ComputeInsight
}
interface ComputeInsightCreationPageProps extends TelemetryProps {
@ -23,6 +34,11 @@ interface ComputeInsightCreationPageProps extends TelemetryProps {
}
export const ComputeInsightCreationPage: FunctionComponent<ComputeInsightCreationPageProps> = props => {
const { telemetryService, onInsightCreateRequest, onSuccessfulCreation, onCancel } = props
const { licensed, insight } = useUiFeatures()
const creationPermission = useObservable(useMemo(() => insight.getCreationPermissions(), [insight]))
// We do not use temporal user settings since form values are not so important to
// waste users time for waiting response of yet another network request to just
// render creation UI form.
@ -36,16 +52,42 @@ export const ComputeInsightCreationPage: FunctionComponent<ComputeInsightCreatio
setInitialFormValues(event.values)
}
const handleSubmit = (): void => {
// TODO: It will be implemented in https://github.com/sourcegraph/sourcegraph/issues/37965
}
const handleSubmit = useCallback(
async (values: CreateComputeInsightFormFields): Promise<SubmissionErrors> => {
try {
const insight = getSanitizedComputeInsight(values)
const handleCancel = (): void => {
// TODO: It will be implemented in https://github.com/sourcegraph/sourcegraph/issues/37965
}
await onInsightCreateRequest({ insight })
// Clear initial values if user successfully created search insight
setInitialFormValues(undefined)
telemetryService.log('CodeInsightsComputeCreationPageSubmitClick')
telemetryService.log(
'InsightAddition',
{ insightType: CodeInsightTrackType.ComputeInsight },
{ insightType: CodeInsightTrackType.ComputeInsight }
)
onSuccessfulCreation()
} catch (error) {
return { [FORM_ERROR]: asError(error) }
}
return
},
[onInsightCreateRequest, onSuccessfulCreation, setInitialFormValues, telemetryService]
)
const handleCancel = useCallback(() => {
// Clear initial values if user successfully created search insight
setInitialFormValues(undefined)
telemetryService.log('CodeInsightsComputeCreationPageCancelClick')
onCancel()
}, [setInitialFormValues, telemetryService, onCancel])
return (
<CodeInsightsPage className="col-12">
<CodeInsightsPage className="col-11">
<PageTitle title="Create compute insight - Code Insights" />
<PageHeader
@ -59,13 +101,25 @@ export const ComputeInsightCreationPage: FunctionComponent<ComputeInsightCreatio
/>
<ComputeInsightCreationContent
touched={false}
initialValue={initialFormValues}
data-testid="search-insight-create-page-content"
className="pb-5"
onChange={handleChange}
onSubmit={handleSubmit}
onCancel={handleCancel}
/>
>
{form => (
<CodeInsightsCreationActions
mode={CodeInsightCreationMode.Creation}
licensed={licensed}
available={creationPermission?.available}
submitting={form.submitting}
errors={form.submitErrors?.[FORM_ERROR]}
clear={form.isFormClearActive}
onCancel={handleCancel}
/>
)}
</ComputeInsightCreationContent>
</CodeInsightsPage>
)
}

View File

@ -1,5 +1,6 @@
import { FC, HTMLAttributes } from 'react'
import { FC, HTMLAttributes, ReactNode } from 'react'
import { GroupByField } from '@sourcegraph/shared/src/graphql-operations'
import { Code, Input, Link } from '@sourcegraph/wildcard'
import {
@ -13,18 +14,16 @@ import {
getDefaultInputProps,
insightRepositoriesAsyncValidator,
insightRepositoriesValidator,
insightSeriesValidator,
insightTitleValidator,
RepositoriesField,
SubmissionErrors,
useField,
EditableDataSeries,
useForm,
} from '../../../../../components'
import { useEditableSeries } from '../../../../../components/creation-ui/form-series/use-editable-series'
import { useUiFeatures } from '../../../../../hooks'
import { ComputeLivePreview } from '../../ComputeLivePreview'
import { getSanitizedSeries } from '../../search-insight/utils/insight-sanitizer'
import { ComputeInsightMap, CreateComputeInsightFormFields } from '../types'
import { CreateComputeInsightFormFields } from '../types'
import { ComputeInsightMapPicker } from './ComputeInsightMapPicker'
@ -32,32 +31,35 @@ const INITIAL_INSIGHT_VALUES: CreateComputeInsightFormFields = {
series: [createDefaultEditSeries({ edit: true })],
title: '',
repositories: '',
groupBy: ComputeInsightMap.Repositories,
groupBy: GroupByField.REPO,
dashboardReferenceCount: 0,
}
type NativeContainerProps = Omit<HTMLAttributes<HTMLDivElement>, 'onSubmit' | 'onChange'>
type NativeContainerProps = Omit<HTMLAttributes<HTMLDivElement>, 'onSubmit' | 'onChange' | 'children'>
export interface RenderPropertyInputs {
submitting: boolean
submitErrors: SubmissionErrors
isFormClearActive: boolean
}
interface ComputeInsightCreationContentProps extends NativeContainerProps {
/** This component might be used in edit or creation insight case. */
mode?: 'creation' | 'edit'
touched: boolean
children: (input: RenderPropertyInputs) => ReactNode
initialValue?: Partial<CreateComputeInsightFormFields>
onChange: (event: FormChangeEvent<CreateComputeInsightFormFields>) => void
onSubmit: (values: CreateComputeInsightFormFields) => SubmissionErrors | Promise<SubmissionErrors> | void
onCancel: () => void
}
export const ComputeInsightCreationContent: FC<ComputeInsightCreationContentProps> = props => {
const { mode = 'creation', initialValue, onChange, onSubmit, onCancel, ...attributes } = props
const { touched, initialValue, onChange, onSubmit, children, ...attributes } = props
const { licensed } = useUiFeatures()
const { formAPI, handleSubmit } = useForm<CreateComputeInsightFormFields>({
const { formAPI, values, handleSubmit } = useForm<CreateComputeInsightFormFields>({
initialValues: { ...INITIAL_INSIGHT_VALUES, ...initialValue },
onSubmit,
onChange,
touched: mode === 'edit',
touched,
})
const title = useField({
@ -79,6 +81,7 @@ export const ComputeInsightCreationContent: FC<ComputeInsightCreationContentProp
const series = useField({
name: 'series',
formApi: formAPI,
validators: { sync: insightSeriesValidator },
})
const groupBy = useField({
@ -86,17 +89,32 @@ export const ComputeInsightCreationContent: FC<ComputeInsightCreationContentProp
formApi: formAPI,
})
const { series: editSeries } = useEditableSeries(series)
const handleFormReset = (): void => {
// TODO [VK] Change useForm API in order to implement form.reset method.
title.input.onChange('')
repositories.input.onChange('')
series.input.onChange([createDefaultEditSeries({ edit: true })])
// Focus first element of the form
repositories.input.ref.current?.focus()
}
const hasFilledValue =
values.series?.some(line => line.name !== '' || line.query !== '') ||
values.repositories !== '' ||
values.title !== ''
// If some fields that needed to run live preview are invalid
// we should disable live chart preview
const allFieldsForPreviewAreValid =
repositories.meta.validState === 'VALID' &&
(series.meta.validState === 'VALID' || editSeries.some(series => series.valid))
(series.meta.validState === 'VALID' || series.meta.value.some(series => series.valid))
const validSeries = series.meta.value.filter(series => series.valid)
return (
<CreationUiLayout {...attributes}>
<CreationUIForm onSubmit={handleSubmit}>
<CreationUIForm noValidate={true} onSubmit={handleSubmit} onReset={handleFormReset}>
<FormGroup
name="insight repositories"
title="Targeted repositories"
@ -120,6 +138,7 @@ export const ComputeInsightCreationContent: FC<ComputeInsightCreationContentProp
innerRef={series.input.ref}
name="data series group"
title="Data series"
error={series.meta.touched && series.meta.error}
subtitle={
licensed
? 'Add any number of data series to your chart'
@ -129,7 +148,8 @@ export const ComputeInsightCreationContent: FC<ComputeInsightCreationContentProp
<FormSeries
seriesField={series}
repositories={repositories.input.value}
showValidationErrorsOnMount={false}
showValidationErrorsOnMount={formAPI.submitted}
showColorPicker={false}
queryFieldDescription={
<ul className="pl-3">
<li>
@ -150,7 +170,7 @@ export const ComputeInsightCreationContent: FC<ComputeInsightCreationContentProp
<hr className="my-4 w-100" />
<FormGroup name="map result" title="Map result">
<ComputeInsightMapPicker series={series.input.value} {...groupBy.input} />
<ComputeInsightMapPicker series={validSeries} {...groupBy.input} />
<small className="text-muted mt-3">
Learn more about <Link to="">grouping results</Link>
@ -169,31 +189,23 @@ export const ComputeInsightCreationContent: FC<ComputeInsightCreationContentProp
{...getDefaultInputProps(title)}
/>
</FormGroup>
<hr className="my-4 w-100" />
{children({
submitting: formAPI.submitting,
submitErrors: formAPI.submitErrors,
isFormClearActive: hasFilledValue,
})}
</CreationUIForm>
<CreationUIPreview
as={ComputeLivePreview}
disabled={!allFieldsForPreviewAreValid}
repositories={repositories.meta.value}
series={seriesToPreview(editSeries)}
series={validSeries}
groupBy={groupBy.meta.value}
/>
</CreationUiLayout>
)
}
function seriesToPreview(
currentSeries: EditableDataSeries[]
): {
query: string
label: string
generatedFromCaptureGroup: boolean
stroke: string
}[] {
const validSeries = currentSeries.filter(series => series.valid)
return getSanitizedSeries(validSeries).map(series => ({
query: series.query,
stroke: series.stroke ? series.stroke : '',
label: series.name,
generatedFromCaptureGroup: false,
}))
}

View File

@ -5,15 +5,15 @@ import { scanSearchQuery } from '@sourcegraph/shared/src/search/query/scanner'
import { Filter } from '@sourcegraph/shared/src/search/query/token'
import { Button, ButtonGroup, ButtonProps, Tooltip } from '@sourcegraph/wildcard'
import { GroupByField } from '../../../../../../../graphql-operations'
import { SearchBasedInsightSeries } from '../../../../../core'
import { ComputeInsightMap } from '../types'
const TOOLTIP_TEXT = 'Available only for queries with type:commit and type:diff'
export interface ComputeInsightMapPickerProps {
series: SearchBasedInsightSeries[]
value: ComputeInsightMap
onChange: (nextValue: ComputeInsightMap) => void
value: GroupByField
onChange: (nextValue: GroupByField) => void
}
export const ComputeInsightMapPicker: FC<ComputeInsightMapPickerProps> = props => {
@ -21,7 +21,7 @@ export const ComputeInsightMapPicker: FC<ComputeInsightMapPickerProps> = props =
const handleOptionClick = (event: MouseEvent<HTMLButtonElement>): void => {
const target = event.target as HTMLButtonElement
const pickedValue = target.value as ComputeInsightMap
const pickedValue = target.value as GroupByField
onChange(pickedValue)
}
@ -47,33 +47,25 @@ export const ComputeInsightMapPicker: FC<ComputeInsightMapPickerProps> = props =
)
useEffect(() => {
if (!hasTypeDiffOrCommit && (value === ComputeInsightMap.Author || value === ComputeInsightMap.Date)) {
onChange(ComputeInsightMap.Repositories)
if (!hasTypeDiffOrCommit && (value === GroupByField.AUTHOR || value === GroupByField.DATE)) {
onChange(GroupByField.REPO)
}
}, [hasTypeDiffOrCommit, value, onChange])
return (
<ButtonGroup className="mb-3 d-block">
<OptionButton
active={value === ComputeInsightMap.Repositories}
value={ComputeInsightMap.Repositories}
onClick={handleOptionClick}
>
<OptionButton active={value === GroupByField.REPO} value={GroupByField.REPO} onClick={handleOptionClick}>
repository
</OptionButton>
<OptionButton
active={value === ComputeInsightMap.Path}
value={ComputeInsightMap.Path}
onClick={handleOptionClick}
>
<OptionButton active={value === GroupByField.PATH} value={GroupByField.PATH} onClick={handleOptionClick}>
path
</OptionButton>
<Tooltip content={!hasTypeDiffOrCommit ? TOOLTIP_TEXT : undefined}>
<OptionButton
active={value === ComputeInsightMap.Author}
value={ComputeInsightMap.Author}
active={value === GroupByField.AUTHOR}
value={GroupByField.AUTHOR}
disabled={!hasTypeDiffOrCommit}
onClick={handleOptionClick}
>
@ -83,8 +75,8 @@ export const ComputeInsightMapPicker: FC<ComputeInsightMapPickerProps> = props =
<Tooltip content={!hasTypeDiffOrCommit ? TOOLTIP_TEXT : undefined}>
<OptionButton
active={value === ComputeInsightMap.Date}
value={ComputeInsightMap.Date}
active={value === GroupByField.DATE}
value={GroupByField.DATE}
disabled={!hasTypeDiffOrCommit}
data-tooltip={!hasTypeDiffOrCommit ? TOOLTIP_TEXT : undefined}
onClick={handleOptionClick}
@ -97,7 +89,7 @@ export const ComputeInsightMapPicker: FC<ComputeInsightMapPickerProps> = props =
}
interface OptionButtonProps extends ButtonProps {
value: ComputeInsightMap
value: GroupByField
active?: boolean
}

View File

@ -1,11 +1,6 @@
import { EditableDataSeries } from '../../../../components'
import { GroupByField } from '@sourcegraph/shared/src/graphql-operations'
export enum ComputeInsightMap {
Repositories = 'repositories',
Path = 'path',
Author = 'author',
Date = 'date',
}
import { EditableDataSeries } from '../../../../components'
export interface CreateComputeInsightFormFields {
/**
@ -28,5 +23,5 @@ export interface CreateComputeInsightFormFields {
*/
dashboardReferenceCount: number
groupBy: ComputeInsightMap
groupBy: GroupByField
}

View File

@ -0,0 +1,21 @@
import { getSanitizedRepositories, getSanitizedSeries } from '../../../../../components'
import { ComputeInsight, InsightExecutionType, InsightType } from '../../../../../core'
import { CreateComputeInsightFormFields } from '../types'
export const getSanitizedComputeInsight = (values: CreateComputeInsightFormFields): ComputeInsight => ({
id: 'newly-created-insight',
title: values.title,
repositories: getSanitizedRepositories(values.repositories),
groupBy: values.groupBy,
type: InsightType.Compute,
executionType: InsightExecutionType.Backend,
dashboards: [],
series: getSanitizedSeries(values.series),
isFrozen: false,
dashboardReferenceCount: 0,
filters: {
excludeRepoRegexp: '',
includeRepoRegexp: '',
context: '',
},
})

View File

@ -10,10 +10,10 @@ import {
SubmissionErrors,
createDefaultEditSeries,
EditableDataSeries,
getSanitizedSeries,
} from '../../../../../components'
import { LineChartLivePreview, LivePreviewSeries } from '../../LineChartLivePreview'
import { CreateInsightFormFields } from '../types'
import { getSanitizedSeries } from '../utils/insight-sanitizer'
import { RenderPropertyInputs, SearchInsightCreationForm } from './SearchInsightCreationForm'
import { useInsightCreationForm } from './use-insight-creation-form'

View File

@ -1,29 +1,7 @@
import { getSanitizedRepositories } from '../../../../../components'
import {
MinimalSearchBasedInsightData,
InsightExecutionType,
InsightType,
SearchBasedInsightSeries,
} from '../../../../../core'
import { getSanitizedRepositories, getSanitizedSeries } from '../../../../../components'
import { MinimalSearchBasedInsightData, InsightExecutionType, InsightType } from '../../../../../core'
import { CreateInsightFormFields } from '../types'
export function getSanitizedLine(line: SearchBasedInsightSeries): SearchBasedInsightSeries {
return {
id: line.id,
name: line.name.trim(),
stroke: line.stroke,
// Query field is a reg exp field for code insight query setting
// Native html input element adds escape symbols by itself
// to prevent this behavior below we replace double escaping
// with just one series of escape characters e.g. - //
query: line.query.replace(/\\\\/g, '\\'),
}
}
export function getSanitizedSeries(rawSeries: SearchBasedInsightSeries[]): SearchBasedInsightSeries[] {
return rawSeries.map(getSanitizedLine)
}
/**
* Function converter from form shape insight to insight as it is
* presented in user/org settings.

View File

@ -2,7 +2,7 @@ import { FunctionComponent } from 'react'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Insight, isBackendInsight } from '../../../../core'
import { Insight, isBackendInsight, isComputeInsight } from '../../../../core'
import { StandaloneBackendInsight } from './standalone-backend-insight/StandaloneBackendInsight'
import { StandaloneRuntimeInsight } from './standalone-runtime-insight/StandaloneRuntimeInsight'
@ -19,6 +19,10 @@ export const SmartStandaloneInsight: FunctionComponent<SmartStandaloneInsightPro
return <StandaloneBackendInsight insight={insight} telemetryService={telemetryService} className={className} />
}
if (isComputeInsight(insight)) {
return null
}
// Search based extension and lang stats insight are handled by built-in fetchers
return <StandaloneRuntimeInsight insight={insight} telemetryService={telemetryService} className={className} />
}

View File

@ -4,6 +4,7 @@ export enum CodeInsightTrackType {
SearchBasedInsight = 'SearchBased',
LangStatsInsight = 'LangStats',
CaptureGroupInsight = 'CaptureGroup',
ComputeInsight = 'ComputeInsight',
InProductLandingPageInsight = 'InProductLandingPageInsight',
CloudLandingPageInsight = 'CloudLandingPageInsight',
}
@ -16,5 +17,7 @@ export const getTrackingTypeByInsightType = (insightType: InsightType): CodeInsi
return CodeInsightTrackType.SearchBasedInsight
case InsightType.LangStats:
return CodeInsightTrackType.LangStatsInsight
case InsightType.Compute:
return CodeInsightTrackType.ComputeInsight
}
}