mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 19:21:50 +00:00
Add live preview chart to lang stats creation UI (#20856)
* Add onChange API for useForm hook * Add live preview for the lang stats creation UI Co-authored-by: Valery Bugakov <skymk1@gmail.com>
This commit is contained in:
parent
6841dfd765
commit
264f0b3fa3
@ -0,0 +1,63 @@
|
||||
import linguistLanguages from 'linguist-languages'
|
||||
import { escapeRegExp, partition, sum } from 'lodash'
|
||||
import { defer } from 'rxjs'
|
||||
import { map, retry } from 'rxjs/operators'
|
||||
import { PieChartContent } from 'sourcegraph'
|
||||
|
||||
import { fetchLangStatsInsight } from '../requests/fetch-lang-stats-insight'
|
||||
import { LangStatsInsightsSettings } from '../types'
|
||||
|
||||
const isLinguistLanguage = (language: string): language is keyof typeof linguistLanguages =>
|
||||
Object.prototype.hasOwnProperty.call(linguistLanguages, language)
|
||||
|
||||
/**
|
||||
* This logic is a simplified copy of fetch logic of lang-stats-based code insight extension.
|
||||
* In order to have live preview for creation UI we had to copy this logic from
|
||||
* extension.
|
||||
* See https://github.com/sourcegraph/sourcegraph-code-stats-insights/blob/master/src/code-stats-insights.ts
|
||||
* */
|
||||
export async function getLangStatsInsightContent(settings: LangStatsInsightsSettings): Promise<PieChartContent<any>> {
|
||||
const { repository, threshold = 0.03 } = settings
|
||||
|
||||
const query = `repo:^${escapeRegExp(repository)}`
|
||||
const stats = await defer(() => fetchLangStatsInsight(query))
|
||||
.pipe(
|
||||
// The search may timeout, but a retry is then likely faster because caches are warm
|
||||
retry(3),
|
||||
map(data => data.search!.stats)
|
||||
)
|
||||
.toPromise()
|
||||
|
||||
if (stats.languages.length === 0) {
|
||||
throw new Error("We couldn't find the language statistics, try changing the repository.")
|
||||
}
|
||||
|
||||
const totalLines = sum(stats.languages.map(language => language.totalLines))
|
||||
const linkURL = new URL('/stats', window.location.origin)
|
||||
|
||||
linkURL.searchParams.set('q', query)
|
||||
|
||||
const [notOther, other] = partition(stats.languages, language => language.totalLines / totalLines >= threshold)
|
||||
return {
|
||||
chart: 'pie' as const,
|
||||
pies: [
|
||||
{
|
||||
data: [
|
||||
...notOther,
|
||||
{
|
||||
name: 'Other',
|
||||
totalLines: sum(other.map(language => language.totalLines)),
|
||||
},
|
||||
].map(language => ({
|
||||
...language,
|
||||
fill: (isLinguistLanguage(language.name) && linguistLanguages[language.name].color) || 'gray',
|
||||
linkURL: linkURL.href,
|
||||
})),
|
||||
dataKey: 'totalLines',
|
||||
nameKey: 'name',
|
||||
fillKey: 'fill',
|
||||
linkURLKey: 'linkURL',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { of, throwError } from 'rxjs'
|
||||
|
||||
import { getCombinedViews, getInsightCombinedViews } from './api/get-combined-views'
|
||||
import { getLangStatsInsightContent } from './api/get-lang-stats-insight-content'
|
||||
import { getSearchInsightContent } from './api/get-search-insight-content'
|
||||
import { getSubjectSettings, updateSubjectSettings } from './api/subject-settings'
|
||||
import { ApiService } from './types'
|
||||
@ -14,6 +15,7 @@ export const createInsightAPI = (): ApiService => ({
|
||||
getSubjectSettings,
|
||||
updateSubjectSettings,
|
||||
getSearchInsightContent,
|
||||
getLangStatsInsightContent,
|
||||
})
|
||||
|
||||
/**
|
||||
@ -26,5 +28,6 @@ export const createMockInsightAPI = (overrideRequests: Partial<ApiService>): Api
|
||||
getSubjectSettings: () => throwError(new Error('Implement getSubjectSettings handler first')),
|
||||
updateSubjectSettings: () => throwError(new Error('Implement getSubjectSettings handler first')),
|
||||
getSearchInsightContent: () => Promise.reject(new Error('Implement getSubjectSettings handler first')),
|
||||
getLangStatsInsightContent: () => Promise.reject(new Error('Implement getLangStatsInsightContent handler first')),
|
||||
...overrideRequests,
|
||||
})
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/shared/src/graphql/graphql'
|
||||
|
||||
import { requestGraphQL } from '../../../../backend/graphql'
|
||||
import { LangStatsInsightContentResult, LangStatsInsightContentVariables } from '../../../../graphql-operations'
|
||||
|
||||
export function fetchLangStatsInsight(query: string): Observable<LangStatsInsightContentResult> {
|
||||
return requestGraphQL<LangStatsInsightContentResult, LangStatsInsightContentVariables>(
|
||||
gql`
|
||||
query LangStatsInsightContent($query: String!) {
|
||||
search(query: $query) {
|
||||
results {
|
||||
limitHit
|
||||
}
|
||||
stats {
|
||||
languages {
|
||||
name
|
||||
totalLines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ query }
|
||||
).pipe(map(dataOrThrowErrors))
|
||||
}
|
||||
@ -28,6 +28,13 @@ export interface SearchInsightSettings {
|
||||
repositories: string[]
|
||||
}
|
||||
|
||||
export interface LangStatsInsightsSettings {
|
||||
/** URL of git repository from which statistics will be collected */
|
||||
repository: string
|
||||
/** The threshold below which a language is counted as part of 'Other' */
|
||||
threshold: number
|
||||
}
|
||||
|
||||
export interface DataSeries {
|
||||
name: string
|
||||
stroke: string
|
||||
@ -38,14 +45,18 @@ export interface ApiService {
|
||||
getCombinedViews: (
|
||||
getExtensionsInsights: () => Observable<ViewProviderResult[]>
|
||||
) => Observable<ViewInsightProviderResult[]>
|
||||
|
||||
getInsightCombinedViews: (
|
||||
extensionApi: Promise<Remote<FlatExtensionHostAPI>>
|
||||
) => Observable<ViewInsightProviderResult[]>
|
||||
|
||||
getSubjectSettings: (id: string) => Observable<SubjectSettingsResult>
|
||||
|
||||
updateSubjectSettings: (
|
||||
context: Pick<PlatformContext, 'updateSettings'>,
|
||||
subjectId: string,
|
||||
content: string
|
||||
) => Observable<void>
|
||||
getSearchInsightContent: (insight: SearchInsightSettings) => Promise<sourcegraph.LineChartContent<any, string>>
|
||||
getLangStatsInsightContent: (insight: LangStatsInsightsSettings) => Promise<sourcegraph.PieChartContent<any>>
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
@import 'wildcard/src/global-styles/breakpoints';
|
||||
|
||||
.creation-page {
|
||||
max-width: $viewport-md;
|
||||
max-width: $viewport-lg;
|
||||
}
|
||||
|
||||
@ -6,7 +6,10 @@ import { EMPTY_SETTINGS_CASCADE } from '@sourcegraph/shared/src/settings/setting
|
||||
|
||||
import { WebStory } from '../../../../components/WebStory'
|
||||
import { authUser } from '../../../../search/panels/utils'
|
||||
import { InsightsApiContext } from '../../../core/backend/api-provider'
|
||||
import { createMockInsightAPI } from '../../../core/backend/insights-api'
|
||||
|
||||
import { getRandomLangStatsMock } from './components/live-preview-chart/live-preview-mock-data'
|
||||
import { LangStatsInsightCreationPage, LangStatsInsightCreationPageProps } from './LangStatsInsightCreationPage'
|
||||
|
||||
const { add } = storiesOf('web/insights/CreateLangStatsInsightPageProps', module)
|
||||
@ -24,13 +27,27 @@ const PLATFORM_CONTEXT: LangStatsInsightCreationPageProps['platformContext'] = {
|
||||
},
|
||||
}
|
||||
|
||||
function sleep(delay: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
|
||||
const mockAPI = createMockInsightAPI({
|
||||
getLangStatsInsightContent: async () => {
|
||||
await sleep(2000)
|
||||
|
||||
return getRandomLangStatsMock()
|
||||
},
|
||||
})
|
||||
|
||||
const history = createMemoryHistory()
|
||||
|
||||
add('Page', () => (
|
||||
<LangStatsInsightCreationPage
|
||||
history={history}
|
||||
platformContext={PLATFORM_CONTEXT}
|
||||
settingsCascade={EMPTY_SETTINGS_CASCADE}
|
||||
authenticatedUser={authUser}
|
||||
/>
|
||||
<InsightsApiContext.Provider value={mockAPI}>
|
||||
<LangStatsInsightCreationPage
|
||||
history={history}
|
||||
platformContext={PLATFORM_CONTEXT}
|
||||
settingsCascade={EMPTY_SETTINGS_CASCADE}
|
||||
authenticatedUser={authUser}
|
||||
/>
|
||||
</InsightsApiContext.Provider>
|
||||
))
|
||||
|
||||
@ -17,9 +17,9 @@ import { InsightsApiContext } from '../../../core/backend/api-provider'
|
||||
import { InsightTypeSuffix } from '../../../core/types'
|
||||
|
||||
import {
|
||||
LangStatsInsightCreationForm,
|
||||
LangStatsInsightCreationFormProps,
|
||||
} from './components/lang-stats-insight-creation-form/LangStatsInsightCreationForm'
|
||||
LangStatsInsightCreationContent,
|
||||
LangStatsInsightCreationContentProps,
|
||||
} from './components/lang-stats-insight-creation-content/LangStatsInsightCreationContent'
|
||||
import styles from './LangStatsInsightCreationPage.module.scss'
|
||||
|
||||
const DEFAULT_FINAL_SETTINGS = {}
|
||||
@ -45,7 +45,7 @@ export const LangStatsInsightCreationPage: React.FunctionComponent<LangStatsInsi
|
||||
const { history, authenticatedUser, settingsCascade, platformContext } = props
|
||||
const { getSubjectSettings, updateSubjectSettings } = useContext(InsightsApiContext)
|
||||
|
||||
const handleSubmit = useCallback<LangStatsInsightCreationFormProps['onSubmit']>(
|
||||
const handleSubmit = useCallback<LangStatsInsightCreationContentProps['onSubmit']>(
|
||||
async values => {
|
||||
if (!authenticatedUser) {
|
||||
return
|
||||
@ -103,7 +103,7 @@ export const LangStatsInsightCreationPage: React.FunctionComponent<LangStatsInsi
|
||||
}
|
||||
|
||||
return (
|
||||
<Page className={classnames(styles.creationPage, 'col-8')}>
|
||||
<Page className={classnames(styles.creationPage, 'col-10')}>
|
||||
<PageTitle title="Create new code insight" />
|
||||
|
||||
<div className="mb-5">
|
||||
@ -121,7 +121,7 @@ export const LangStatsInsightCreationPage: React.FunctionComponent<LangStatsInsi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LangStatsInsightCreationForm
|
||||
<LangStatsInsightCreationContent
|
||||
className="pb-5"
|
||||
settings={settingsCascade.final ?? DEFAULT_FINAL_SETTINGS}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
@import 'wildcard/src/global-styles/breakpoints';
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__form {
|
||||
order: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 25rem;
|
||||
}
|
||||
|
||||
&__live-preview {
|
||||
order: 2;
|
||||
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
padding-left: 1rem;
|
||||
|
||||
flex-basis: 25rem;
|
||||
flex-shrink: 0;
|
||||
height: 20rem;
|
||||
}
|
||||
|
||||
@media (--md-breakpoint-down) {
|
||||
&__live-preview {
|
||||
order: 1;
|
||||
flex-basis: 100%;
|
||||
padding-left: 0;
|
||||
margin-bottom: 2rem;
|
||||
position: static;
|
||||
}
|
||||
|
||||
&__form {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import classnames from 'classnames'
|
||||
import React from 'react'
|
||||
import { noop } from 'rxjs'
|
||||
|
||||
import { Settings } from '@sourcegraph/shared/src/settings/settings'
|
||||
|
||||
import { useField } from '../../../../../components/form/hooks/useField'
|
||||
import { SubmissionErrors, useForm } from '../../../../../components/form/hooks/useForm'
|
||||
import { useTitleValidator } from '../../../../../components/form/hooks/useTitleValidator'
|
||||
import { createRequiredValidator } from '../../../../../components/form/validators'
|
||||
import { InsightTypeSuffix } from '../../../../../core/types'
|
||||
import { LangStatsCreationFormFields } from '../../types'
|
||||
import { LangStatsInsightCreationForm } from '../lang-stats-insight-creation-form/LangStatsInsightCreationForm'
|
||||
import { LangStatsInsightLivePreview } from '../live-preview-chart/LangStatsInsightLivePreview'
|
||||
|
||||
import styles from './LangStatsInsightCreationContent.module.scss'
|
||||
|
||||
const repositoriesFieldValidator = createRequiredValidator('Repositories is a required field for code insight.')
|
||||
const thresholdFieldValidator = createRequiredValidator('Threshold is a required field for code insight.')
|
||||
|
||||
const INITIAL_VALUES: LangStatsCreationFormFields = {
|
||||
repository: '',
|
||||
title: '',
|
||||
threshold: 3,
|
||||
visibility: 'personal',
|
||||
}
|
||||
|
||||
export interface LangStatsInsightCreationContentProps {
|
||||
/** Final settings cascade. Used for title field validation. */
|
||||
settings?: Settings | null
|
||||
/** Initial value for all form fields. */
|
||||
initialValues?: LangStatsCreationFormFields
|
||||
/** Custom class name for root form element. */
|
||||
className?: string
|
||||
/** Submit handler for form element. */
|
||||
onSubmit: (values: LangStatsCreationFormFields) => SubmissionErrors | Promise<SubmissionErrors> | void
|
||||
/** Cancel handler. */
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export const LangStatsInsightCreationContent: React.FunctionComponent<LangStatsInsightCreationContentProps> = props => {
|
||||
const { settings, initialValues = INITIAL_VALUES, className, onSubmit, onCancel = noop } = props
|
||||
|
||||
const { handleSubmit, formAPI, ref } = useForm<LangStatsCreationFormFields>({
|
||||
initialValues,
|
||||
onSubmit,
|
||||
})
|
||||
|
||||
// We can't have two or more insights with the same name, since we rely on name as on id of insights.
|
||||
const titleValidator = useTitleValidator({ settings, insightType: InsightTypeSuffix.langStats })
|
||||
|
||||
const repository = useField('repository', formAPI, repositoriesFieldValidator)
|
||||
const title = useField('title', formAPI, titleValidator)
|
||||
const threshold = useField('threshold', formAPI, thresholdFieldValidator)
|
||||
const visibility = useField('visibility', formAPI)
|
||||
|
||||
// If some fields that needed to run live preview are invalid
|
||||
// we should disabled live chart preview
|
||||
const allFieldsForPreviewAreValid = repository.meta.validState === 'VALID' && threshold.meta.validState === 'VALID'
|
||||
|
||||
return (
|
||||
<div className={classnames(styles.content, className)}>
|
||||
<LangStatsInsightCreationForm
|
||||
innerRef={ref}
|
||||
handleSubmit={handleSubmit}
|
||||
submitErrors={formAPI.submitErrors}
|
||||
submitting={formAPI.submitting}
|
||||
title={title}
|
||||
repository={repository}
|
||||
threshold={threshold}
|
||||
visibility={visibility}
|
||||
onCancel={onCancel}
|
||||
className={styles.contentForm}
|
||||
/>
|
||||
|
||||
<LangStatsInsightLivePreview
|
||||
repository={repository.meta.value}
|
||||
threshold={threshold.meta.value}
|
||||
disabled={!allFieldsForPreviewAreValid}
|
||||
className={styles.contentLivePreview}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,65 +1,50 @@
|
||||
import classnames from 'classnames'
|
||||
import React from 'react'
|
||||
|
||||
import { Settings } from '@sourcegraph/shared/src/settings/settings'
|
||||
import React, { FormEventHandler, RefObject } from 'react'
|
||||
|
||||
import { ErrorAlert } from '../../../../../../components/alerts'
|
||||
import { LoaderButton } from '../../../../../../components/LoaderButton'
|
||||
import { FormGroup } from '../../../../../components/form/form-group/FormGroup'
|
||||
import { FormInput } from '../../../../../components/form/form-input/FormInput'
|
||||
import { FormRadioInput } from '../../../../../components/form/form-radio-input/FormRadioInput'
|
||||
import { useField } from '../../../../../components/form/hooks/useField'
|
||||
import { FORM_ERROR, SubmissionErrors, useForm } from '../../../../../components/form/hooks/useForm'
|
||||
import { useTitleValidator } from '../../../../../components/form/hooks/useTitleValidator'
|
||||
import { createRequiredValidator } from '../../../../../components/form/validators'
|
||||
import { InsightTypeSuffix } from '../../../../../core/types'
|
||||
import { useFieldAPI } from '../../../../../components/form/hooks/useField'
|
||||
import { FORM_ERROR, SubmissionErrors } from '../../../../../components/form/hooks/useForm'
|
||||
import { LangStatsCreationFormFields } from '../../types'
|
||||
|
||||
import styles from './LangStatsInsightCreationForm.module.scss'
|
||||
|
||||
const repositoriesFieldValidator = createRequiredValidator('Repositories is a required field for code insight.')
|
||||
const thresholdFieldValidator = createRequiredValidator('Threshold is a required field for code insight.')
|
||||
|
||||
export interface LangStatsInsightCreationFormProps {
|
||||
settings: Settings | null
|
||||
innerRef: RefObject<any>
|
||||
handleSubmit: FormEventHandler
|
||||
submitErrors: SubmissionErrors
|
||||
submitting: boolean
|
||||
className?: string
|
||||
onSubmit: (values: LangStatsCreationFormFields) => SubmissionErrors | Promise<SubmissionErrors> | void
|
||||
|
||||
title: useFieldAPI<LangStatsCreationFormFields['title']>
|
||||
repository: useFieldAPI<LangStatsCreationFormFields['repository']>
|
||||
threshold: useFieldAPI<LangStatsCreationFormFields['threshold']>
|
||||
visibility: useFieldAPI<LangStatsCreationFormFields['visibility']>
|
||||
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export interface LangStatsCreationFormFields {
|
||||
title: string
|
||||
repository: string
|
||||
threshold: number
|
||||
visibility: 'personal' | 'organization'
|
||||
}
|
||||
|
||||
const INITIAL_VALUES: LangStatsCreationFormFields = {
|
||||
repository: '',
|
||||
title: '',
|
||||
threshold: 3,
|
||||
visibility: 'personal',
|
||||
}
|
||||
|
||||
export const LangStatsInsightCreationForm: React.FunctionComponent<LangStatsInsightCreationFormProps> = props => {
|
||||
const { className, onSubmit, onCancel, settings } = props
|
||||
|
||||
const { handleSubmit, formAPI, ref } = useForm<LangStatsCreationFormFields>({
|
||||
initialValues: INITIAL_VALUES,
|
||||
onSubmit,
|
||||
})
|
||||
|
||||
// We can't have two or more insights with the same name, since we rely on name as on id of insights.
|
||||
const titleValidator = useTitleValidator({ settings, insightType: InsightTypeSuffix.langStats })
|
||||
|
||||
const repository = useField('repository', formAPI, repositoriesFieldValidator)
|
||||
const title = useField('title', formAPI, titleValidator)
|
||||
const threshold = useField('threshold', formAPI, thresholdFieldValidator)
|
||||
const visibility = useField('visibility', formAPI)
|
||||
const {
|
||||
innerRef,
|
||||
handleSubmit,
|
||||
submitErrors,
|
||||
submitting,
|
||||
className,
|
||||
title,
|
||||
repository,
|
||||
threshold,
|
||||
visibility,
|
||||
onCancel,
|
||||
} = props
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/forbid-elements
|
||||
<form
|
||||
ref={ref}
|
||||
ref={innerRef}
|
||||
noValidate={true}
|
||||
className={classnames(className, 'd-flex flex-column')}
|
||||
onSubmit={handleSubmit}
|
||||
@ -134,14 +119,14 @@ export const LangStatsInsightCreationForm: React.FunctionComponent<LangStatsInsi
|
||||
<hr className={styles.formSeparator} />
|
||||
|
||||
<div>
|
||||
{formAPI.submitErrors?.[FORM_ERROR] && <ErrorAlert error={formAPI.submitErrors[FORM_ERROR]} />}
|
||||
{submitErrors?.[FORM_ERROR] && <ErrorAlert error={submitErrors[FORM_ERROR]} />}
|
||||
|
||||
<LoaderButton
|
||||
alwaysShowLabel={true}
|
||||
loading={formAPI.submitting}
|
||||
label={formAPI.submitting ? 'Submitting' : 'Create code insight'}
|
||||
loading={submitting}
|
||||
label={submitting ? 'Submitting' : 'Create code insight'}
|
||||
type="submit"
|
||||
disabled={formAPI.submitting}
|
||||
disabled={submitting}
|
||||
className="btn btn-primary mr-2"
|
||||
/>
|
||||
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
.live-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__update-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-grow: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
&__chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&__chart {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__chart--with-mock-data {
|
||||
filter: blur(5px);
|
||||
pointer-events: none;
|
||||
|
||||
// In order to turn off any interactions with chart like
|
||||
// tooltip or chart shutter for user cursor we have to
|
||||
// override pointer events. Since visx charts add pointer events
|
||||
// by html attribute we have to use important statement.
|
||||
:global(.visx-group) {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__update-button-icon {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
&__loading-chart-info {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
&__loader {
|
||||
background-color: var(--color-bg-2);
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import classnames from 'classnames'
|
||||
import RefreshIcon from 'mdi-react/RefreshIcon'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
|
||||
import { LoadingSpinner } from '@sourcegraph/react-loading-spinner'
|
||||
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { isErrorLike } from '@sourcegraph/shared/src/util/errors'
|
||||
|
||||
import { ErrorAlert } from '../../../../../../components/alerts'
|
||||
import { ChartViewContent } from '../../../../../../views/ChartViewContent/ChartViewContent'
|
||||
|
||||
import { useLangStatsPreviewContent } from './hooks/use-lang-stats-preview-content'
|
||||
import styles from './LangStatsInsightLivePreview.module.scss'
|
||||
import { DEFAULT_PREVIEW_MOCK } from './live-preview-mock-data'
|
||||
|
||||
export interface LangStatsInsightLivePreviewProps {
|
||||
/** Custom className for the root element of live preview. */
|
||||
className?: string
|
||||
/** List of repositories for insights. */
|
||||
repository: string
|
||||
/** Step value for cut off other small values. */
|
||||
threshold: number
|
||||
/**
|
||||
* Disable prop to disable live preview.
|
||||
* Used in a consumer of this component when some required fields
|
||||
* for live preview are invalid.
|
||||
* */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays live preview chart for creation UI with latest insights settings
|
||||
* from creation UI form.
|
||||
* */
|
||||
export const LangStatsInsightLivePreview: React.FunctionComponent<LangStatsInsightLivePreviewProps> = props => {
|
||||
const { repository = '', threshold, disabled = false, className } = props
|
||||
|
||||
const history = useHistory()
|
||||
|
||||
const previewSetting = useMemo(
|
||||
() => ({
|
||||
repository: repository.trim(),
|
||||
threshold: threshold / 100,
|
||||
}),
|
||||
[repository, threshold]
|
||||
)
|
||||
|
||||
const { loading, dataOrError, update } = useLangStatsPreviewContent({ disabled, previewSetting })
|
||||
|
||||
return (
|
||||
<div className={classnames(styles.livePreview, className)}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={classnames('btn btn-secondary', styles.livePreviewUpdateButton)}
|
||||
onClick={update}
|
||||
>
|
||||
Update live preview
|
||||
<RefreshIcon size="1rem" className={styles.livePreviewUpdateButtonIcon} />
|
||||
</button>
|
||||
|
||||
{loading && (
|
||||
<div
|
||||
className={classnames(
|
||||
styles.livePreviewLoader,
|
||||
'flex-grow-1 d-flex flex-column align-items-center justify-content-center'
|
||||
)}
|
||||
>
|
||||
<LoadingSpinner /> Loading code insight
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isErrorLike(dataOrError) && <ErrorAlert className="m-0" error={dataOrError} />}
|
||||
|
||||
{!loading && !isErrorLike(dataOrError) && (
|
||||
<div className={styles.livePreviewChartContainer}>
|
||||
<ChartViewContent
|
||||
className={classnames(styles.livePreviewChart, {
|
||||
[styles.livePreviewChartWithMockData]: !dataOrError,
|
||||
})}
|
||||
history={history}
|
||||
viewID="search-insight-live-preview"
|
||||
telemetryService={NOOP_TELEMETRY_SERVICE}
|
||||
content={dataOrError ?? DEFAULT_PREVIEW_MOCK}
|
||||
/>
|
||||
|
||||
{!dataOrError && (
|
||||
<p className={styles.livePreviewLoadingChartInfo}>
|
||||
Here you’ll see your insight’s chart preview
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { PieChartContent } from 'sourcegraph'
|
||||
|
||||
import { asError } from '@sourcegraph/shared/src/util/errors'
|
||||
import { useDebounce } from '@sourcegraph/wildcard/src'
|
||||
|
||||
import { InsightsApiContext } from '../../../../../../core/backend/api-provider'
|
||||
|
||||
export interface UseLangStatsPreviewContentProps {
|
||||
/** Prop to turn off fetching and reset data for live preview chart. */
|
||||
disabled: boolean
|
||||
/** Settings which needed to fetch data for live preview. */
|
||||
previewSetting: {
|
||||
repository: string
|
||||
threshold: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseLangStatsPreviewContentAPI {
|
||||
loading: boolean
|
||||
dataOrError: PieChartContent<any> | Error | undefined
|
||||
update: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified logic for fetching data for live preview chart of lang stats insight.
|
||||
* */
|
||||
export function useLangStatsPreviewContent(props: UseLangStatsPreviewContentProps): UseLangStatsPreviewContentAPI {
|
||||
const { disabled, previewSetting } = props
|
||||
|
||||
const { getLangStatsInsightContent } = useContext(InsightsApiContext)
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [dataOrError, setDataOrError] = useState<PieChartContent<any> | Error | undefined>()
|
||||
|
||||
// Synthetic deps to trigger dry run for fetching live preview data
|
||||
const [lastPreviewVersion, setLastPreviewVersion] = useState(0)
|
||||
|
||||
const liveDebouncedSettings = useDebounce(previewSetting, 500)
|
||||
|
||||
useEffect(() => {
|
||||
let hasRequestCanceled = false
|
||||
setLoading(true)
|
||||
setDataOrError(undefined)
|
||||
|
||||
if (disabled) {
|
||||
setLoading(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
getLangStatsInsightContent(liveDebouncedSettings)
|
||||
.then(data => !hasRequestCanceled && setDataOrError(data))
|
||||
.catch(error => !hasRequestCanceled && setDataOrError(asError(error)))
|
||||
.finally(() => !hasRequestCanceled && setLoading(false))
|
||||
|
||||
return () => {
|
||||
hasRequestCanceled = true
|
||||
}
|
||||
}, [disabled, lastPreviewVersion, getLangStatsInsightContent, liveDebouncedSettings])
|
||||
|
||||
return {
|
||||
loading,
|
||||
dataOrError,
|
||||
update: () => setLastPreviewVersion(count => count + 1),
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { random } from 'lodash'
|
||||
import { PieChartContent } from 'sourcegraph'
|
||||
|
||||
export const DEFAULT_PREVIEW_MOCK: PieChartContent<any> = {
|
||||
chart: 'pie' as const,
|
||||
pies: [
|
||||
{
|
||||
dataKey: 'value',
|
||||
nameKey: 'name',
|
||||
fillKey: 'fill',
|
||||
linkURLKey: 'linkURL',
|
||||
data: [
|
||||
{
|
||||
name: 'Covered',
|
||||
value: 0.3,
|
||||
fill: 'var(--oc-grape-7)',
|
||||
linkURL: '#Covered',
|
||||
},
|
||||
{
|
||||
name: 'Not covered',
|
||||
value: 0.7,
|
||||
fill: 'var(--oc-orange-7)',
|
||||
linkURL: '#Not_covered',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function getRandomLangStatsMock(): PieChartContent<any> {
|
||||
const randomFirstPieValue = random(0, 0.6)
|
||||
const randomSecondPieValue = 1 - randomFirstPieValue
|
||||
|
||||
return {
|
||||
chart: 'pie' as const,
|
||||
pies: [
|
||||
{
|
||||
dataKey: 'value',
|
||||
nameKey: 'name',
|
||||
fillKey: 'fill',
|
||||
linkURLKey: 'linkURL',
|
||||
data: [
|
||||
{
|
||||
name: 'JavaScript',
|
||||
value: randomFirstPieValue,
|
||||
fill: 'var(--oc-grape-7)',
|
||||
linkURL: '#Covered',
|
||||
},
|
||||
{
|
||||
name: 'Typescript',
|
||||
value: randomSecondPieValue,
|
||||
fill: 'var(--oc-orange-7)',
|
||||
linkURL: '#Not_covered',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export interface LangStatsCreationFormFields {
|
||||
title: string
|
||||
repository: string
|
||||
threshold: number
|
||||
visibility: 'personal' | 'organization'
|
||||
}
|
||||
@ -141,7 +141,7 @@ const config = {
|
||||
...(shouldAnalyze ? [new BundleAnalyzerPlugin()] : []),
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.mjs', '.ts', '.tsx', '.js'],
|
||||
extensions: ['.mjs', '.ts', '.tsx', '.js', '.json'],
|
||||
mainFields: ['es2015', 'module', 'browser', 'main'],
|
||||
alias: {
|
||||
// react-visibility-sensor's main field points to a UMD bundle instead of ESM
|
||||
|
||||
@ -337,6 +337,7 @@
|
||||
"is-absolute-url": "^3.0.3",
|
||||
"iterare": "^1.2.1",
|
||||
"js-cookie": "^2.2.1",
|
||||
"linguist-languages": "^7.14.0",
|
||||
"lodash": "^4.17.20",
|
||||
"marked": "^1.2.7",
|
||||
"mdi-react": "^7.3.0",
|
||||
|
||||
@ -14900,6 +14900,11 @@ lines-and-columns@^1.1.6:
|
||||
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
|
||||
integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
|
||||
|
||||
linguist-languages@^7.14.0:
|
||||
version "7.14.0"
|
||||
resolved "https://registry.npmjs.org/linguist-languages/-/linguist-languages-7.14.0.tgz#62a6bdbe1ef80d0715d16c0bb8e953ee93c95dfc"
|
||||
integrity sha512-VqnUYHOSqRqAGnIl+7SCnFxK+sX0x7LXe5qn0TG6t9SViofQgN7272PLCFZ/lgkT7tAO5CA/2pCsZGlGvGhfWA==
|
||||
|
||||
linkify-it@^2.0.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.npmjs.org/linkify-it/-/linkify-it-2.0.3.tgz#d94a4648f9b1c179d64fa97291268bdb6ce9434f"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user