diff --git a/client/web/src/charts/components/pie-chart/PieChart.tsx b/client/web/src/charts/components/pie-chart/PieChart.tsx index 03db6e511a64..0a1982c5b6cc 100644 --- a/client/web/src/charts/components/pie-chart/PieChart.tsx +++ b/client/web/src/charts/components/pie-chart/PieChart.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useMemo, useState } from 'react' +import React, { ReactElement, SVGProps, useMemo, useState } from 'react' import { Group } from '@visx/group' import Pie, { PieArcDatum } from '@visx/shape/lib/shapes/Pie' @@ -25,7 +25,7 @@ function getSubtitle<Datum>(arc: PieArcDatum<Datum>, total: number): string { return `${((100 * arc.value) / total).toFixed(2)}%` } -export interface PieChartProps<Datum> extends CategoricalLikeChart<Datum> { +export interface PieChartProps<Datum> extends CategoricalLikeChart<Datum>, SVGProps<SVGSVGElement> { width: number height: number padding?: typeof DEFAULT_PADDING @@ -40,8 +40,10 @@ export function PieChart<Datum>(props: PieChartProps<Datum>): ReactElement | nul getDatumValue, getDatumName, getDatumColor, - getDatumLink, + getDatumLink = noop, onDatumLinkClick = noop, + className, + ...attributes } = props // We have to track which arc is hovered to change order of rendering. @@ -76,7 +78,13 @@ export function PieChart<Datum>(props: PieChartProps<Datum>): ReactElement | nul } return ( - <svg aria-label="Pie chart" width={width} height={height} className={styles.svg}> + <svg + {...attributes} + aria-label="Pie chart" + width={width} + height={height} + className={classNames(styles.svg, className)} + > <Group top={centerY + padding.top} left={centerX + padding.left}> <Pie data={sortedData} pieValue={getDatumValue} outerRadius={radius} cornerRadius={3}> {pie => { diff --git a/client/web/src/charts/types.ts b/client/web/src/charts/types.ts index d8624008bdf2..b47a44e5b40a 100644 --- a/client/web/src/charts/types.ts +++ b/client/web/src/charts/types.ts @@ -18,7 +18,7 @@ export interface CategoricalLikeChart<Datum> { getDatumValue: (datum: Datum) => number getDatumName: (datum: Datum) => string getDatumColor: (datum: Datum) => string | undefined - getDatumLink: (datum: Datum) => string | undefined + getDatumLink?: (datum: Datum) => string | undefined | void onDatumLinkClick?: (event: React.MouseEvent) => void } diff --git a/client/web/src/enterprise/insights/components/views/card/InsightCard.module.scss b/client/web/src/enterprise/insights/components/views/card/InsightCard.module.scss new file mode 100644 index 000000000000..a9f07bf2fba6 --- /dev/null +++ b/client/web/src/enterprise/insights/components/views/card/InsightCard.module.scss @@ -0,0 +1,58 @@ +.view { + display: flex; + flex-direction: column; + padding: 1rem 1rem; + cursor: default; + outline: none; + + &:focus, + &:focus-within { + box-shadow: var(--focus-box-shadow); + } +} + +.header { + margin-bottom: 0.75rem; +} + +.header-content { + display: flex; +} + +.title { + // Truncation for multiple lines + // stylelint-disable-next-line value-no-vendor-prefix + display: -webkit-box; + overflow: hidden; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + margin-bottom: 0.25rem; + flex-grow: 1; +} + +.action { + align-self: start; + display: flex; + align-items: center; + // Move icons in action panel visually closer to card border + margin-right: -0.5rem; + margin-top: -0.25rem; +} + +.subtitle { + flex-basis: 100%; +} + +.error-boundary { + padding-top: 0; +} + +// Content states + +.loading-content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/client/web/src/enterprise/insights/components/views/card/InsightCard.story.tsx b/client/web/src/enterprise/insights/components/views/card/InsightCard.story.tsx new file mode 100644 index 000000000000..264aac6e3419 --- /dev/null +++ b/client/web/src/enterprise/insights/components/views/card/InsightCard.story.tsx @@ -0,0 +1,184 @@ +import React from 'react' + +import { Meta, Story } from '@storybook/react' +import { noop } from 'lodash' +import DotsVerticalIcon from 'mdi-react/DotsVerticalIcon' +import FilterOutlineIcon from 'mdi-react/FilterOutlineIcon' + +import { ErrorAlert } from '@sourcegraph/branded/src/components/alerts' +import { Button, Menu, MenuButton, MenuItem, MenuList } from '@sourcegraph/wildcard' + +import { getLineColor, LegendItem, LegendList, ParentSize, Series } from '../../../../../charts' +import { WebStory } from '../../../../../components/WebStory' +import { SeriesChart } from '../chart' +import { SeriesBasedChartTypes } from '../types' + +import * as Card from './InsightCard' + +export default { + title: 'web/insights/shared-components', + decorators: [story => <WebStory>{() => story()}</WebStory>], +} as Meta + +export const InsightCardShowcase: Story = () => ( + <main style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}> + <section> + <h2>Empty view</h2> + <Card.Root style={{ width: '400px', height: '400px' }}> + <Card.Header title="Empty card" /> + </Card.Root> + </section> + + <section> + <h2>View with loading content</h2> + <Card.Root style={{ width: '400px', height: '400px' }}> + <Card.Header title="Loading insight card" subtitle="View with loading content example" /> + <Card.Loading>Loading insight</Card.Loading> + </Card.Root> + </section> + + <section> + <h2>View with error-like content</h2> + <Card.Root style={{ width: '400px', height: '400px' }}> + <Card.Header title="Loading insight card" subtitle="View with errored content example" /> + <ErrorAlert error={new Error("We couldn't find code insight")} /> + </Card.Root> + </section> + + <section> + <h2>Card with banner content (resizing state)</h2> + <Card.Root style={{ width: '400px', height: '400px' }}> + <Card.Header title="Resizing insight card" subtitle="Resizing insight card" /> + <Card.Banner>Resizing</Card.Banner> + </Card.Root> + </section> + + <section> + <h2>Card with insight chart</h2> + <InsightCardWithChart /> + </section> + + <section> + <h2>View with context action item</h2> + <Card.Root style={{ width: 400, height: 400 }}> + <Card.Header + title="Chart view and looooooong loooooooooooooooong name of insight card block" + subtitle="Subtitle chart description" + > + <Button variant="icon" className="p-1"> + <FilterOutlineIcon size="1rem" /> + </Button> + <Menu> + <MenuButton variant="icon" className="p-1"> + <DotsVerticalIcon size={16} /> + </MenuButton> + <MenuList> + <MenuItem onSelect={noop}>Create</MenuItem> + <MenuItem onSelect={noop}>Update</MenuItem> + <MenuItem onSelect={noop}>Delete</MenuItem> + </MenuList> + </Menu> + </Card.Header> + </Card.Root> + </section> + </main> +) + +interface StandardDatum { + a: number | null + b: number | null + c: number | null + x: number +} + +const DATA: StandardDatum[] = [ + { + x: 1588965700286 - 4 * 24 * 60 * 60 * 1000, + a: 4000, + b: 15000, + c: 5000, + }, + { + x: 1588965700286 - 3 * 24 * 60 * 60 * 1000, + a: 4000, + b: 26000, + c: 5000, + }, + { + x: 1588965700286 - 2 * 24 * 60 * 60 * 1000, + a: 5600, + b: 20000, + c: 5000, + }, + { + x: 1588965700286 - 24 * 60 * 60 * 1000, + a: 9800, + b: 19000, + c: 5000, + }, + { + x: 1588965700286, + a: 6000, + b: 17000, + c: 5000, + }, +] + +const SERIES: Series<StandardDatum>[] = [ + { + dataKey: 'a', + name: 'A metric', + color: 'var(--blue)', + }, + { + dataKey: 'b', + name: 'B metric', + color: 'var(--orange)', + }, + { + dataKey: 'c', + name: 'C metric', + color: 'var(--indigo)', + }, +] + +const getXValue = (datum: { x: number }) => new Date(datum.x) + +function InsightCardWithChart() { + return ( + <Card.Root style={{ width: '400px', height: '400px' }}> + <Card.Header title="Insight with chart" subtitle="CSS migration insight chart"> + <Button variant="icon" className="p-1"> + <FilterOutlineIcon size="1rem" /> + </Button> + <Menu> + <MenuButton variant="icon" className="p-1"> + <DotsVerticalIcon size={16} /> + </MenuButton> + <MenuList> + <MenuItem onSelect={noop}>Create</MenuItem> + <MenuItem onSelect={noop}>Update</MenuItem> + <MenuItem onSelect={noop}>Delete</MenuItem> + </MenuList> + </Menu> + </Card.Header> + <ParentSize> + {parent => ( + <SeriesChart + type={SeriesBasedChartTypes.Line} + data={DATA} + series={SERIES} + getXValue={getXValue} + width={parent.width} + height={parent.height} + /> + )} + </ParentSize> + <LegendList className="mt-3"> + {SERIES.map(line => ( + <LegendItem key={line.dataKey.toString()} color={getLineColor(line)} name={line.name} /> + ))} + </LegendList> + </Card.Root> + ) +} diff --git a/client/web/src/enterprise/insights/components/views/card/InsightCard.tsx b/client/web/src/enterprise/insights/components/views/card/InsightCard.tsx new file mode 100644 index 000000000000..58e39460c8ac --- /dev/null +++ b/client/web/src/enterprise/insights/components/views/card/InsightCard.tsx @@ -0,0 +1,93 @@ +import React, { forwardRef, HTMLAttributes, ReactNode } from 'react' + +import classNames from 'classnames' +import { useLocation } from 'react-router-dom' + +import { Card, ForwardReferenceComponent, LoadingSpinner } from '@sourcegraph/wildcard' + +import { ErrorBoundary } from '../../../../../components/ErrorBoundary' + +import styles from './InsightCard.module.scss' + +const InsightCard = forwardRef((props, reference) => { + const { title, children, className, as = 'section', ...otherProps } = props + + return ( + <Card as={as} tabIndex={0} ref={reference} {...otherProps} className={classNames(className, styles.view)}> + <ErrorBoundary location={useLocation()} className={styles.errorBoundary}> + {children} + </ErrorBoundary> + </Card> + ) +}) as ForwardReferenceComponent<'section'> + +interface InsightCardTitleProps { + title: string + subtitle?: string + + /** + * It's primarily conceived as a slot for card actions (like filter buttons) that render + * element right after header text at the right top corner of the card. + */ + children?: ReactNode +} + +const InsightCardHeader = forwardRef((props, reference) => { + const { as: Component = 'header', title, subtitle, className, children, ...attributes } = props + + return ( + <Component {...attributes} ref={reference} className={classNames(styles.header, className)}> + <div className={styles.headerContent}> + <h4 title={title} className={styles.title}> + {title} + </h4> + + {children && ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions + <div + // View component usually is rendered within a view grid component. To suppress + // bad click that lead to card DnD events in view grid we stop event bubbling for + // clicks. + onClick={event => event.stopPropagation()} + onMouseDown={event => event.stopPropagation()} + className={styles.action} + > + {children} + </div> + )} + </div> + + {subtitle && <div className={styles.subtitle}>{subtitle}</div>} + </Component> + ) +}) as ForwardReferenceComponent<'header', InsightCardTitleProps> + +const InsightCardLoading: React.FunctionComponent = props => ( + <InsightCardBanner> + <LoadingSpinner /> + {props.children} + </InsightCardBanner> +) + +const InsightCardBanner: React.FunctionComponent<HTMLAttributes<HTMLDivElement>> = props => ( + <div {...props} className={classNames(styles.loadingContent, props.className)}> + {props.children} + </div> +) + +const Root = InsightCard +const Header = InsightCardHeader +const Loading = InsightCardLoading +const Banner = InsightCardBanner + +export { + InsightCard, + InsightCardHeader, + InsightCardLoading, + InsightCardBanner, + // * as Card imports + Root, + Header, + Loading, + Banner, +} diff --git a/client/web/src/enterprise/insights/components/views/categorical/CategoricalChart.story.tsx b/client/web/src/enterprise/insights/components/views/chart/categorical/CategoricalChart.story.tsx similarity index 92% rename from client/web/src/enterprise/insights/components/views/categorical/CategoricalChart.story.tsx rename to client/web/src/enterprise/insights/components/views/chart/categorical/CategoricalChart.story.tsx index 64759e049d9f..09d6d01b20eb 100644 --- a/client/web/src/enterprise/insights/components/views/categorical/CategoricalChart.story.tsx +++ b/client/web/src/enterprise/insights/components/views/chart/categorical/CategoricalChart.story.tsx @@ -2,8 +2,8 @@ import React from 'react' import { Meta, Story } from '@storybook/react' -import { WebStory } from '../../../../../components/WebStory' -import { CategoricalBasedChartTypes } from '../types' +import { WebStory } from '../../../../../../components/WebStory' +import { CategoricalBasedChartTypes } from '../../types' import { CategoricalChart } from './CategoricalChart' diff --git a/client/web/src/enterprise/insights/components/views/categorical/CategoricalChart.tsx b/client/web/src/enterprise/insights/components/views/chart/categorical/CategoricalChart.tsx similarity index 67% rename from client/web/src/enterprise/insights/components/views/categorical/CategoricalChart.tsx rename to client/web/src/enterprise/insights/components/views/chart/categorical/CategoricalChart.tsx index 6596c4c6d1fd..28286dab9724 100644 --- a/client/web/src/enterprise/insights/components/views/categorical/CategoricalChart.tsx +++ b/client/web/src/enterprise/insights/components/views/chart/categorical/CategoricalChart.tsx @@ -1,9 +1,9 @@ -import React from 'react' +import React, { SVGProps } from 'react' -import { CategoricalLikeChart, PieChart } from '../../../../../charts' -import { CategoricalBasedChartTypes } from '../types' +import { CategoricalLikeChart, PieChart } from '../../../../../../charts' +import { CategoricalBasedChartTypes } from '../../types' -interface CategoricalChartProps<Datum> extends CategoricalLikeChart<Datum> { +interface CategoricalChartProps<Datum> extends CategoricalLikeChart<Datum>, Omit<SVGProps<SVGSVGElement>, 'type'> { type: CategoricalBasedChartTypes width: number height: number diff --git a/client/web/src/enterprise/insights/components/views/chart/index.ts b/client/web/src/enterprise/insights/components/views/chart/index.ts new file mode 100644 index 000000000000..d89f5e167132 --- /dev/null +++ b/client/web/src/enterprise/insights/components/views/chart/index.ts @@ -0,0 +1,2 @@ +export { SeriesChart } from './series/SeriesChart' +export { CategoricalChart } from './categorical/CategoricalChart' diff --git a/client/web/src/enterprise/insights/components/views/series/SeriesChart.story.tsx b/client/web/src/enterprise/insights/components/views/chart/series/SeriesChart.story.tsx similarity index 93% rename from client/web/src/enterprise/insights/components/views/series/SeriesChart.story.tsx rename to client/web/src/enterprise/insights/components/views/chart/series/SeriesChart.story.tsx index b0a3c45d2f79..9cce25abd790 100644 --- a/client/web/src/enterprise/insights/components/views/series/SeriesChart.story.tsx +++ b/client/web/src/enterprise/insights/components/views/chart/series/SeriesChart.story.tsx @@ -2,9 +2,9 @@ import React from 'react' import { Meta, Story } from '@storybook/react' -import { Series } from '../../../../../charts' -import { WebStory } from '../../../../../components/WebStory' -import { SeriesBasedChartTypes } from '../types' +import { Series } from '../../../../../../charts' +import { WebStory } from '../../../../../../components/WebStory' +import { SeriesBasedChartTypes } from '../../types' import { SeriesChart } from './SeriesChart' diff --git a/client/web/src/enterprise/insights/components/views/series/SeriesChart.tsx b/client/web/src/enterprise/insights/components/views/chart/series/SeriesChart.tsx similarity index 73% rename from client/web/src/enterprise/insights/components/views/series/SeriesChart.tsx rename to client/web/src/enterprise/insights/components/views/chart/series/SeriesChart.tsx index 187dd39d0cb9..15545e01563f 100644 --- a/client/web/src/enterprise/insights/components/views/series/SeriesChart.tsx +++ b/client/web/src/enterprise/insights/components/views/chart/series/SeriesChart.tsx @@ -1,7 +1,7 @@ import React from 'react' -import { LineChart, SeriesLikeChart } from '../../../../../charts' -import { SeriesBasedChartTypes } from '../types' +import { LineChart, SeriesLikeChart } from '../../../../../../charts' +import { SeriesBasedChartTypes } from '../../types' export interface SeriesChartProps<D> extends SeriesLikeChart<D> { type: SeriesBasedChartTypes diff --git a/client/web/src/enterprise/insights/components/views/index.ts b/client/web/src/enterprise/insights/components/views/index.ts new file mode 100644 index 000000000000..e82fe4c8c5e6 --- /dev/null +++ b/client/web/src/enterprise/insights/components/views/index.ts @@ -0,0 +1,4 @@ +export { InsightCard, InsightCardHeader, InsightCardBanner, InsightCardLoading } from './card/InsightCard' + +export { SeriesChart, CategoricalChart } from './chart' +export { CategoricalBasedChartTypes, SeriesBasedChartTypes } from './types' diff --git a/client/web/src/enterprise/insights/core/backend/code-insights-backend-context.ts b/client/web/src/enterprise/insights/core/backend/code-insights-backend-context.ts index d1c262d1dcef..85990ecf4f83 100644 --- a/client/web/src/enterprise/insights/core/backend/code-insights-backend-context.ts +++ b/client/web/src/enterprise/insights/core/backend/code-insights-backend-context.ts @@ -1,10 +1,10 @@ import React from 'react' import { throwError } from 'rxjs' -import { LineChartContent, PieChartContent } from 'sourcegraph' +import { LineChartContent } from 'sourcegraph' import { CodeInsightsBackend } from './code-insights-backend' -import { RepositorySuggestionData } from './code-insights-backend-types' +import { PieChartContent, RepositorySuggestionData } from './code-insights-backend-types' const errorMockMethod = (methodName: string) => () => throwError(new Error(`Implement ${methodName} method first`)) diff --git a/client/web/src/enterprise/insights/core/backend/code-insights-backend-types.ts b/client/web/src/enterprise/insights/core/backend/code-insights-backend-types.ts index 6504e1a2446e..057dab51e3ff 100644 --- a/client/web/src/enterprise/insights/core/backend/code-insights-backend-types.ts +++ b/client/web/src/enterprise/insights/core/backend/code-insights-backend-types.ts @@ -8,8 +8,17 @@ import { CaptureGroupInsight, LangStatsInsight, InsightsDashboardOwner, + SearchBackendBasedInsight, + SearchRuntimeBasedInsight, } from '../types' -import { SearchBackendBasedInsight, SearchRuntimeBasedInsight } from '../types/insight/types/search-insight' + +export interface PieChartContent<Datum> { + data: Datum[] + getDatumValue: (datum: Datum) => number + getDatumName: (datum: Datum) => string + getDatumColor: (datum: Datum) => string | undefined + getDatumLink?: (datum: Datum) => string | undefined +} export interface DashboardCreateInput { name: string diff --git a/client/web/src/enterprise/insights/core/backend/code-insights-backend.ts b/client/web/src/enterprise/insights/core/backend/code-insights-backend.ts index 5fb8d005a437..e649619b51f7 100644 --- a/client/web/src/enterprise/insights/core/backend/code-insights-backend.ts +++ b/client/web/src/enterprise/insights/core/backend/code-insights-backend.ts @@ -1,5 +1,5 @@ import { Observable } from 'rxjs' -import { LineChartContent, PieChartContent } from 'sourcegraph' +import { LineChartContent } from 'sourcegraph' import { ViewProviderResult } from '@sourcegraph/shared/src/api/extension/extensionHostApi' @@ -23,6 +23,7 @@ import { AccessibleInsightInfo, RemoveInsightFromDashboardInput, RepositorySuggestionData, + PieChartContent, } from './code-insights-backend-types' export interface UiFeaturesConfig { @@ -109,7 +110,7 @@ export interface CodeInsightsBackend { /** * Returns content for the code stats insight live preview chart. */ - getLangStatsInsightContent: (input: GetLangStatsInsightContentInput) => Promise<PieChartContent<any>> + getLangStatsInsightContent: (input: GetLangStatsInsightContentInput) => Promise<PieChartContent<unknown>> getCaptureInsightContent: (input: CaptureInsightSettings) => Promise<LineChartContent<any, string>> diff --git a/client/web/src/enterprise/insights/core/backend/gql-backend/code-insights-gql-backend.ts b/client/web/src/enterprise/insights/core/backend/gql-backend/code-insights-gql-backend.ts index 5bb3827357ce..76648eb8e54d 100644 --- a/client/web/src/enterprise/insights/core/backend/gql-backend/code-insights-gql-backend.ts +++ b/client/web/src/enterprise/insights/core/backend/gql-backend/code-insights-gql-backend.ts @@ -1,7 +1,7 @@ import { ApolloCache, ApolloClient, ApolloQueryResult, gql } from '@apollo/client' import { from, Observable, of } from 'rxjs' import { map, mapTo, switchMap } from 'rxjs/operators' -import { LineChartContent, PieChartContent } from 'sourcegraph' +import { LineChartContent } from 'sourcegraph' import { AddInsightViewToDashboardResult, DeleteDashboardResult, @@ -35,6 +35,7 @@ import { InsightCreateInput, InsightUpdateInput, RemoveInsightFromDashboardInput, + PieChartContent, } from '../code-insights-backend-types' import { getRepositorySuggestions } from '../core/api/get-repository-suggestions' import { getResolvedSearchRepositories } from '../core/api/get-resolved-search-repositories' @@ -230,7 +231,17 @@ export class CodeInsightsGqlBackend implements CodeInsightsBackend { getSearchInsightContent(input.insight) public getLangStatsInsightContent = (input: GetLangStatsInsightContentInput): Promise<PieChartContent<any>> => - getLangStatsInsightContent(input.insight) + getLangStatsInsightContent(input.insight).then(data => { + const { data: dataList, dataKey, nameKey, fillKey = '', linkURLKey = '' } = data.pies[0] + + return { + data: dataList, + getDatumValue: datum => datum[dataKey], + getDatumColor: datum => datum[fillKey ?? ''], + getDatumName: datum => datum[nameKey], + getDatumLink: datum => datum[linkURLKey], + } + }) public getCaptureInsightContent = (input: CaptureInsightSettings): Promise<LineChartContent<any, string>> => getCaptureGroupInsightsPreview(this.apolloClient, input) diff --git a/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/LangStatsInsightLivePreview.module.scss b/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/LangStatsInsightLivePreview.module.scss new file mode 100644 index 000000000000..533c4881b270 --- /dev/null +++ b/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/LangStatsInsightLivePreview.module.scss @@ -0,0 +1,32 @@ +.insight-card { + position: relative; + min-height: 20rem; +} + +.chart-block { + flex: 1; +} + +.chart-with-mock { + filter: blur(4px); + pointer-events: none; + opacity: 0.4; + + // 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; + } +} + +.disable-banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 1rem 2rem; + text-align: center; +} diff --git a/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/LangStatsInsightLivePreview.tsx b/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/LangStatsInsightLivePreview.tsx index 84ded9530809..4faf1d34c2bc 100644 --- a/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/LangStatsInsightLivePreview.tsx +++ b/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/LangStatsInsightLivePreview.tsx @@ -1,37 +1,46 @@ import React from 'react' -import { LivePreviewContainer } from '../../../../../../components/creation-ui-kit/live-preview-container/LivePreviewContainer' -import { useDistinctValue } from '../../../../../../hooks/use-distinct-value' +import classNames from 'classnames' +import RefreshIcon from 'mdi-react/RefreshIcon' + +import { ErrorAlert } from '@sourcegraph/branded/src/components/alerts' +import { isErrorLike } from '@sourcegraph/common' +import { Button, useDeepMemo } from '@sourcegraph/wildcard' + +import { ParentSize } from '../../../../../../../../charts' +import { + CategoricalBasedChartTypes, + CategoricalChart, + InsightCard, + InsightCardLoading, + InsightCardBanner, +} from '../../../../../../components/views' import { useLangStatsPreviewContent } from './hooks/use-lang-stats-preview-content' 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 +import styles from './LangStatsInsightLivePreview.module.scss' +export interface LangStatsInsightLivePreviewProps { /** * Disable prop to disable live preview. * Used in a consumer of this component when some required fields * for live preview are invalid. */ disabled?: boolean + repository: string + threshold: number + className?: string } /** * 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 previewSetting = useDistinctValue({ + const previewSetting = useDeepMemo({ repository: repository.trim(), otherThreshold: threshold / 100, }) @@ -39,16 +48,45 @@ export const LangStatsInsightLivePreview: React.FunctionComponent<LangStatsInsig const { loading, dataOrError, update } = useLangStatsPreviewContent({ disabled, previewSetting }) return ( - <LivePreviewContainer - dataOrError={dataOrError} - disabled={disabled} - className={className} - loading={loading} - defaultMock={DEFAULT_PREVIEW_MOCK} - onUpdateClick={update} - mockMessage={ - <span>The chart preview will be shown here once you have filled out the repository field.</span> - } - /> + <aside className={classNames(className)}> + <Button variant="icon" disabled={disabled} onClick={update}> + Live preview <RefreshIcon size="1rem" /> + </Button> + + <InsightCard className={styles.insightCard}> + {loading ? ( + <InsightCardLoading>Loading code insight</InsightCardLoading> + ) : isErrorLike(dataOrError) ? ( + <ErrorAlert error={dataOrError} /> + ) : ( + <ParentSize className={styles.chartBlock}> + {parent => + dataOrError ? ( + <CategoricalChart + type={CategoricalBasedChartTypes.Pie} + width={parent.width} + height={parent.height} + {...dataOrError} + /> + ) : ( + <> + <CategoricalChart + type={CategoricalBasedChartTypes.Pie} + width={parent.width} + height={parent.height} + className={styles.chartWithMock} + {...DEFAULT_PREVIEW_MOCK} + /> + <InsightCardBanner className={styles.disableBanner}> + The chart preview will be shown here once you have filled out the repository + field. + </InsightCardBanner> + </> + ) + } + </ParentSize> + )} + </InsightCard> + </aside> ) } diff --git a/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/hooks/use-lang-stats-preview-content.ts b/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/hooks/use-lang-stats-preview-content.ts index 1faca37ab6b8..10db614c0e97 100644 --- a/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/hooks/use-lang-stats-preview-content.ts +++ b/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/hooks/use-lang-stats-preview-content.ts @@ -1,11 +1,10 @@ import { useContext, useEffect, useState } from 'react' -import { PieChartContent } from 'sourcegraph' - import { asError } from '@sourcegraph/common' import { useDebounce } from '@sourcegraph/wildcard' import { CodeInsightsBackendContext } from '../../../../../../../core/backend/code-insights-backend-context' +import { PieChartContent } from '../../../../../../../core/backend/code-insights-backend-types' export interface UseLangStatsPreviewContentProps { /** Prop to turn off fetching and reset data for live preview chart. */ @@ -19,13 +18,13 @@ export interface UseLangStatsPreviewContentProps { export interface UseLangStatsPreviewContentAPI { loading: boolean - dataOrError: PieChartContent<any> | Error | undefined + dataOrError: PieChartContent<object> | 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 diff --git a/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/live-preview-mock-data.ts b/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/live-preview-mock-data.ts index 71ad5ea1724f..6a7ff95155d5 100644 --- a/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/live-preview-mock-data.ts +++ b/client/web/src/enterprise/insights/pages/insights/creation/lang-stats/components/live-preview-chart/live-preview-mock-data.ts @@ -1,59 +1,50 @@ import { random } from 'lodash' -import { PieChartContent } from 'sourcegraph' -export const DEFAULT_PREVIEW_MOCK: PieChartContent<any> = { - chart: 'pie' as const, - pies: [ +import { PieChartContent } from '../../../../../../core/backend/code-insights-backend-types' + +interface PreviewDatum { + name: string + value: number + fill: string +} + +export const DEFAULT_PREVIEW_MOCK: PieChartContent<PreviewDatum> = { + data: [ { - 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', - }, - ], + name: 'Covered', + value: 0.3, + fill: 'var(--oc-grape-7)', + }, + { + name: 'Not covered', + value: 0.7, + fill: 'var(--oc-orange-7)', }, ], + getDatumName: datum => datum.name, + getDatumColor: datum => datum.fill, + getDatumValue: datum => datum.value, } -export function getRandomLangStatsMock(): PieChartContent<any> { +export function getRandomLangStatsMock(): PieChartContent<PreviewDatum> { const randomFirstPieValue = random(0, 0.6) const randomSecondPieValue = 1 - randomFirstPieValue return { - chart: 'pie' as const, - pies: [ + data: [ + { + name: 'JavaScript', + value: randomFirstPieValue, + fill: 'var(--oc-grape-7)', + }, { - 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', - }, - ], + name: 'Typescript', + value: randomSecondPieValue, + fill: 'var(--oc-orange-7)', }, ], + getDatumName: datum => datum.name, + getDatumColor: datum => datum.fill, + getDatumValue: datum => datum.value, } } diff --git a/client/web/src/views/components/view/content/chart-view-content/charts/MaybeLink.tsx b/client/web/src/views/components/view/content/chart-view-content/charts/MaybeLink.tsx index 298278217cad..0d45614e2b51 100644 --- a/client/web/src/views/components/view/content/chart-view-content/charts/MaybeLink.tsx +++ b/client/web/src/views/components/view/content/chart-view-content/charts/MaybeLink.tsx @@ -3,7 +3,7 @@ import React from 'react' import { Link } from '@sourcegraph/wildcard' interface MaybeLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> { - to?: string + to?: string | void } /** Wraps the children in a link if to (link href) prop is passed. */