[Code] Embedded Code Snippet Component (#47183) (#47391)

* Add Integrations page and POC CodeBlock usage

* Adds new route hidden behind a config var
(`xpack.code.integrations.enabld`)
* Updates CodeBlock component to have a smaller API surface
* Adds several example components for various pieces of the new APM
integration designs.

* Pare down stub data

Hits and ranges were both part of our search results queries; O11y won't
have that information.
This commit is contained in:
Ryland Herrick 2019-10-07 15:12:46 -05:00 committed by GitHub
parent 573d48db4d
commit 3d3aa912de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 434 additions and 69 deletions

View file

@ -39,6 +39,7 @@ export const code = (kibana: any) =>
const config = server.config();
return {
codeUiEnabled: config.get('xpack.code.ui.enabled'),
codeIntegrationsEnabled: config.get('xpack.code.integrations.enabled'),
};
},
hacks: ['plugins/code/hacks/toggle_app_link_in_nav'],
@ -50,6 +51,9 @@ export const code = (kibana: any) =>
ui: Joi.object({
enabled: Joi.boolean().default(true),
}).default(),
integrations: Joi.object({
enabled: Joi.boolean().default(false),
}).default(),
enabled: Joi.boolean().default(true),
}).default();
},

View file

@ -7,6 +7,7 @@
import React from 'react';
import { HashRouter as Router, Redirect, Switch } from 'react-router-dom';
import chrome from 'ui/chrome';
import { connect } from 'react-redux';
import { RootState } from '../reducers';
import { Admin } from './admin_page/admin';
@ -17,6 +18,7 @@ import { NotFound } from './main/not_found';
import { Route } from './route';
import * as ROUTES from './routes';
import { Search } from './search_page/search';
import { Integrations } from './integrations';
const RooComponent = (props: { setupOk?: boolean }) => {
if (props.setupOk) {
@ -33,6 +35,8 @@ const Root = connect(mapStateToProps)(RooComponent);
const Empty = () => null;
const integrationsEnabled = chrome.getInjected('codeIntegrationsEnabled');
export const App = () => {
return (
<Router>
@ -45,6 +49,9 @@ export const App = () => {
<Route path={ROUTES.SEARCH} component={Search} />
<Route path={ROUTES.REPO} render={Empty} exact={true} />
<Route path={ROUTES.SETUP} component={SetupGuide} exact={true} />
{integrationsEnabled && (
<Route path={ROUTES.INTEGRATIONS} component={Integrations} exact={true} />
)}
<Route path="*" component={NotFound} />
</Switch>
</Router>

View file

@ -5,50 +5,72 @@
*/
import { EuiPanel } from '@elastic/eui';
import { editor, IPosition, IRange } from 'monaco-editor';
import { editor, IRange } from 'monaco-editor';
import React from 'react';
import { ResizeChecker } from '../shared/resize_checker';
import { monaco } from '../../monaco/monaco';
import { registerEditor } from '../../monaco/single_selection_helper';
interface Props {
code: string;
fileComponent?: React.ReactNode;
startLine?: number;
language?: string;
highlightRanges?: IRange[];
onClick?: (event: IPosition) => void;
export interface Position {
lineNumber: string;
column: number;
}
export interface Props {
content: string;
header: React.ReactNode;
language: string;
highlightRanges: IRange[];
onClick: (event: Position) => void;
folding: boolean;
lineNumbersFunc: (line: number) => string;
/**
* Returns the line number to display for a given line.
* @param lineIndex The index of the given line (0-indexed)
*/
lineNumber: (lineIndex: number) => string;
className?: string;
}
export class CodeBlock extends React.PureComponent<Props> {
static defaultProps = {
header: undefined,
folding: false,
highlightRanges: [],
language: 'text',
lineNumber: String,
onClick: () => {},
};
private el: HTMLDivElement | null = null;
private ed?: editor.IStandaloneCodeEditor;
private resizeChecker?: ResizeChecker;
private currentHighlightDecorations: string[] = [];
public async componentDidMount() {
const { content, highlightRanges, language, onClick } = this.props;
if (this.el) {
await this.tryLoadFile(this.props.code, this.props.language || 'text');
await this.tryLoadFile(content, language);
this.ed!.onMouseDown((e: editor.IEditorMouseEvent) => {
if (
this.props.onClick &&
onClick &&
(e.target.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS ||
e.target.type === monaco.editor.MouseTargetType.CONTENT_TEXT)
) {
const position = e.target.position || { lineNumber: 0, column: 0 };
const lineNumber = (this.props.startLine || 0) + position.lineNumber;
this.props.onClick({
const lineNumber = this.lineNumber(position.lineNumber);
onClick({
lineNumber,
column: position.column,
});
}
});
registerEditor(this.ed!);
if (this.props.highlightRanges) {
const decorations = this.props.highlightRanges.map((range: IRange) => {
if (highlightRanges.length) {
const decorations = highlightRanges.map((range: IRange) => {
return {
range,
options: {
@ -66,6 +88,7 @@ export class CodeBlock extends React.PureComponent<Props> {
});
}
}
private async tryLoadFile(code: string, language: string) {
try {
await monaco.editor.colorize(code, language, {});
@ -79,7 +102,7 @@ export class CodeBlock extends React.PureComponent<Props> {
this.ed = monaco.editor.create(this.el!, {
value: code,
language,
lineNumbers: this.lineNumbersFunc.bind(this),
lineNumbers: this.lineNumber,
readOnly: true,
folding: this.props.folding,
minimap: {
@ -103,17 +126,16 @@ export class CodeBlock extends React.PureComponent<Props> {
}
public componentDidUpdate(prevProps: Readonly<Props>) {
if (
prevProps.code !== this.props.code ||
prevProps.highlightRanges !== this.props.highlightRanges
) {
const { content, highlightRanges } = this.props;
if (prevProps.content !== content || prevProps.highlightRanges !== highlightRanges) {
if (this.ed) {
const model = this.ed.getModel();
if (model) {
model.setValue(this.props.code);
model.setValue(content);
if (this.props.highlightRanges) {
const decorations = this.props.highlightRanges!.map((range: IRange) => {
if (highlightRanges.length) {
const decorations = highlightRanges!.map((range: IRange) => {
return {
range,
options: {
@ -138,19 +160,20 @@ export class CodeBlock extends React.PureComponent<Props> {
}
public render() {
const linesCount = this.props.code.split('\n').length;
const { className, header } = this.props;
const height = this.lines.length * 18;
return (
<EuiPanel style={{ marginBottom: '2rem' }} paddingSize="s">
{this.props.fileComponent}
<div ref={r => (this.el = r)} style={{ height: linesCount * 18 }} />
<EuiPanel paddingSize="s" className={className}>
{header}
<div ref={r => (this.el = r)} style={{ height }} />
</EuiPanel>
);
}
private lineNumbersFunc = (line: number) => {
if (this.props.lineNumbersFunc) {
return this.props.lineNumbersFunc(line);
}
return `${(this.props.startLine || 0) + line}`;
};
private lineNumber = (lineIndex: number) => this.props.lineNumber(lineIndex - 1);
private get lines(): string[] {
return this.props.content.split('\n');
}
}

View file

@ -14,13 +14,12 @@ import {
EuiTitle,
} from '@elastic/eui';
import classname from 'classnames';
import { IPosition } from 'monaco-editor';
import queryString from 'querystring';
import React from 'react';
import { parseSchema } from '../../../common/uri_util';
import { GroupedFileResults, GroupedRepoResults } from '../../actions';
import { history } from '../../utils/url';
import { CodeBlock } from '../codeblock/codeblock';
import { CodeBlock, Position } from '../codeblock/codeblock';
interface Props {
isLoading: boolean;
@ -114,12 +113,9 @@ export class ReferencesPanel extends React.Component<Props, State> {
private renderReference(file: GroupedFileResults) {
const key = `${file.uri}`;
const lineNumberFn = (l: number) => {
return file.lineNumbers[l - 1];
};
const fileComponent = (
const header = (
<React.Fragment>
<EuiText>
<EuiText size="s">
<a href={`#${this.computeUrl(file.uri)}`}>{file.file}</a>
</EuiText>
<EuiSpacer size="s" />
@ -128,23 +124,22 @@ export class ReferencesPanel extends React.Component<Props, State> {
return (
<CodeBlock
className="referencesPanel__code-block"
key={key}
header={header}
content={file.code}
language={file.language}
startLine={0}
code={file.code}
folding={false}
lineNumbersFunc={lineNumberFn}
lineNumber={i => file.lineNumbers[i]}
highlightRanges={file.highlights}
fileComponent={fileComponent}
onClick={this.onCodeClick.bind(this, file.lineNumbers, file.uri)}
onClick={this.onCodeClick(file.uri)}
/>
);
}
private onCodeClick(lineNumbers: string[], url: string, pos: IPosition) {
const line = parseInt(lineNumbers[pos.lineNumber - 1], 10);
history.push(this.computeUrl(url, line));
}
private onCodeClick = (url: string) => (position: Position) => {
const lineNum = parseInt(position.lineNumber, 10);
history.push(this.computeUrl(url, lineNum));
};
private computeUrl(url: string, line?: number) {
const { uri } = parseSchema(url)!;
@ -158,6 +153,7 @@ export class ReferencesPanel extends React.Component<Props, State> {
tab: 'references',
refUrl: this.props.refUrl,
});
return line !== undefined ? `${uri}!L${line}:0?${query}` : `${uri}?${query}`;
}
}

View file

@ -0,0 +1,159 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface Snippet {
uri: string;
filePath: string;
language?: string;
compositeContent: {
content: string;
lineMapping: string[];
};
}
export type Results = Record<string, Snippet>;
export const results: Results = {
'ringside.ts#L18': {
uri: 'github.com/rylnd/ringside',
filePath: 'src/ringside.ts',
language: 'typescript',
compositeContent: {
content:
"\nimport { fitsInside, fitsOutside } from './fitting';\n\nexport interface RingsideInterface {\n positions(): FittedPosition[];\n}\n\nclass Ringside implements RingsideInterface {\n readonly innerBounds: FullRect;\n readonly outerBounds: FullRect;\n\n}\n\nexport default Ringside;\n",
lineMapping: [
'..',
'13',
'14',
'15',
'16',
'17',
'18',
'19',
'20',
'21',
'..',
'67',
'68',
'69',
'70',
],
},
},
'ringside.story.tsx#L12': {
uri: 'github.com/rylnd/ringside',
filePath: 'stories/ringside.story.tsx',
language: 'typescript',
compositeContent: {
content:
"\nimport { interpolateRainbow } from 'd3-scale-chromatic';\n\nimport { Ringside } from '../src';\nimport { XAlignment, YAlignment, XBasis, YBasis } from '../src/types';\n\nlet ringside: Ringside;\n\nconst enumKeys: (e: any) => string[] = e =>\n\n\nconst color = position => {\n const combos = ringside.positions().map(p => JSON.stringify(p));\n const hash = combos.indexOf(JSON.stringify(position)) / combos.length;\n\n\n};\n\nconst Stories = storiesOf('Ringside', module).addDecorator(withKnobs);\n\nStories.add('Ringside', () => {\n",
lineMapping: [
'..',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'..',
'14',
'15',
'16',
'17',
'18',
'..',
'20',
'21',
'22',
'23',
'24',
'..',
],
},
},
'ringside.story.tsx#L8': {
uri: 'github.com/rylnd/ringside',
filePath: 'stories/ringside.story.tsx',
language: 'typescript',
compositeContent: {
content:
"import { Ringside } from '../src';\n\ndescribe('Ringside', () => {\n let inner;\n let outer;\n let height;\n let width;\n let ringside: Ringside;\n\n beforeEach(() => {\n\n width = 50;\n\n ringside = new Ringside(inner, outer, height, width);\n });\n\n",
lineMapping: [
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'..',
'14',
'15',
'16',
'17',
'18',
'..',
],
},
},
'ringside.story.tsx#L14': {
uri: 'github.com/rylnd/ringside',
filePath: 'stories/ringside.story.tsx',
language: 'typescript',
compositeContent: {
content:
"import { Ringside } from '../src';\n\ndescribe('Ringside', () => {\n let inner;\n let outer;\n let height;\n let width;\n let ringside: Ringside;\n\n beforeEach(() => {\n\n width = 50;\n\n ringside = new Ringside(inner, outer, height, width);\n });\n\n",
lineMapping: [
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'..',
'14',
'15',
'16',
'17',
'18',
'..',
],
},
},
};
export interface Frame {
fileName: string;
lineNumber: number;
functionName?: string;
}
export const frames: Frame[] = [
{ fileName: 'ringside.ts', lineNumber: 18 },
{ fileName: 'node_modules/library_code.js', lineNumber: 100 },
{ fileName: 'ringside.story.tsx', lineNumber: 8 },
{ fileName: 'node_modules/other_stuff.js', lineNumber: 58 },
{ fileName: 'node_modules/other/other.js', lineNumber: 3 },
{ fileName: 'ringside.story.tsx', lineNumber: 12 },
{ fileName: 'ringside.story.tsx', lineNumber: 14 },
];
export const repos = [
'https://github.com/a/repo',
'https://github.com/another/repo',
'https://github.com/also/a_repo',
];

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiButtonIcon, EuiFlexGroup, EuiLink, EuiText, EuiTextColor } from '@elastic/eui';
export const FrameHeader = ({
fileName,
lineNumber,
onClick,
}: {
fileName: string;
lineNumber: number | string;
onClick: () => void;
}) => (
<EuiFlexGroup
className="integrations__snippet-info"
alignItems="center"
justifyContent="spaceBetween"
gutterSize="none"
>
<EuiText size="s">
<EuiLink onClick={onClick}>{fileName}</EuiLink>
<span> at </span>
<EuiLink onClick={onClick}>line {lineNumber}</EuiLink>
</EuiText>
<EuiText size="xs">
<EuiTextColor color="subdued">Last updated: 14 mins ago</EuiTextColor>
<EuiButtonIcon
className="integrations__link--external integrations__button-icon"
iconType="codeApp"
onClick={onClick}
/>
</EuiText>
</EuiFlexGroup>
);

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// TODO(rylnd): make this an actual external link
export const externalFileURI: (repoUri: string, filePath: string) => string = (uri, path) =>
`/${uri}/blob/HEAD/${path}`;

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiFlexGroup, EuiText } from '@elastic/eui';
import { CodeBlock } from '../codeblock/codeblock';
import { history } from '../../utils/url';
import { FrameHeader } from './frame_header';
import { RepoTitle } from './repo_title';
import { externalFileURI } from './helpers';
import { frames, results } from './data';
export const Integrations = () => (
<div className="codeContainer__root integrations__container">
{frames.map(frame => {
const { fileName, lineNumber } = frame;
const key = `${fileName}#L${lineNumber}`;
const snippet = results[key];
if (snippet) {
const { compositeContent, filePath, language, uri } = snippet;
const { content, lineMapping } = compositeContent;
const fileUrl = externalFileURI(uri, filePath);
return (
<div key={key} className="integrations__frame">
<RepoTitle uri={snippet.uri} />
<CodeBlock
content={content}
header={
<FrameHeader
fileName={fileName}
lineNumber={lineNumber}
onClick={() => history.push(fileUrl)}
/>
}
language={language}
lineNumber={i => lineMapping[i]}
/>
</div>
);
}
return (
<div key={key} className="integrations__frame">
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="none" alignItems="center">
<EuiText size="s" className="integrations__code">
<span>{fileName}</span>
<span className="integrations__preposition">at</span>
<span>line {lineNumber}</span>
</EuiText>
</EuiFlexGroup>
</div>
);
})}
</div>
);

View file

@ -0,0 +1,43 @@
.integrations__container {
padding: $euiSize;
}
.integrations__frame {
margin: $euiSizeS 0;
}
.integrations__code {
@include euiCodeFont;
}
.integrations__link--external {
margin-left: $euiSizeS;
}
.integrations__preposition {
margin: 0 $euiSizeS;
color: $euiColorMediumShade;
}
.integrations__button-icon {
padding: $euiSizeXS;
background-color: $euiColorLightestShade;
border: 1px solid $euiColorLightShade;
}
.integrations__snippet-info {
margin-bottom: $euiSizeS;
}
.integrations__snippet-title {
margin-bottom: $euiSizeS;
}
.integrations__text--bold {
font-weight: $euiFontWeightBold;
}
.integrations__popover {
margin-bottom: 1rem;
width: 300px;
}

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiText } from '@elastic/eui';
import { RepositoryUtils } from '../../../common/repository_utils';
export const RepoTitle = ({ uri }: { uri: string }) => {
const org = RepositoryUtils.orgNameFromUri(uri);
const name = RepositoryUtils.repoNameFromUri(uri);
return (
<EuiText size="s" className="integrations__snippet-title">
<span>{org}/</span>
<span className="integrations__text--bold">{name}</span>
</EuiText>
);
};

View file

@ -15,3 +15,4 @@ export const REPO = `/:resource/:org/:repo`;
export const MAIN_ROOT = `/:resource/:org/:repo/${pathTypes}/:revision`;
export const ADMIN = '/admin';
export const SEARCH = '/search';
export const INTEGRATIONS = '/integrations';

View file

@ -6,13 +6,12 @@
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { IPosition } from 'monaco-editor';
import React from 'react';
import { Link } from 'react-router-dom';
import { RepositoryUtils } from '../../../common/repository_utils';
import { history } from '../../utils/url';
import { CodeBlock } from '../codeblock/codeblock';
import { CodeBlock, Position } from '../codeblock/codeblock';
interface Props {
query: string;
@ -22,15 +21,14 @@ interface Props {
export class CodeResult extends React.PureComponent<Props> {
public render() {
const { results, query } = this.props;
return results.map(item => {
const { uri, filePath, hits, compositeContent } = item;
const { compositeContent, filePath, hits, language, uri } = item;
const { content, lineMapping, ranges } = compositeContent;
const repoLinkUrl = `/${uri}/tree/HEAD/`;
const fileLinkUrl = `/${uri}/blob/HEAD/${filePath}`;
const key = `${uri}-${filePath}-${query}`;
const lineMappingFunc = (l: number) => {
return lineMapping[l - 1];
};
const repoLinkUrl = `/${uri}/tree/HEAD/`;
const fileLinkUrl = `/${uri}/blob/HEAD/${filePath}`; // TODO(rylnd) move these to link helpers
return (
<div key={`resultitem${key}`} data-test-subj="codeSearchResultList">
<div style={{ marginBottom: '.5rem' }}>
@ -75,23 +73,23 @@ export class CodeResult extends React.PureComponent<Props> {
</EuiFlexGroup>
<CodeBlock
key={`code${key}`}
language={item.language}
startLine={0}
code={content}
className="codeResult__code-block"
content={content}
language={language}
highlightRanges={ranges}
folding={false}
lineNumbersFunc={lineMappingFunc}
onClick={this.onCodeClick.bind(this, lineMapping, fileLinkUrl)}
lineNumber={i => lineMapping[i]}
onClick={this.onCodeClick(fileLinkUrl)}
/>
</div>
);
});
}
private onCodeClick(lineNumbers: string[], fileUrl: string, pos: IPosition) {
const line = parseInt(lineNumbers[pos.lineNumber - 1], 10);
if (!isNaN(line)) {
history.push(`${fileUrl}!L${line}:0`);
private onCodeClick = (url: string) => (position: Position) => {
const lineNumber = parseInt(position.lineNumber, 10);
if (!isNaN(lineNumber)) {
history.push(`${url}!L${lineNumber}:0`);
}
}
};
}

View file

@ -4,6 +4,7 @@
@import "./monaco/override_monaco_styles.scss";
@import "./components/diff_page/diff.scss";
@import "./components/main/main.scss";
@import "./components/integrations/integrations.scss";
// TODO: Cleanup everything above this line

View file

@ -9,6 +9,7 @@
.monaco-editor.mac .margin-view-overlays .line-numbers {
cursor: pointer;
color: $euiColorMediumShade;
background-color: $euiColorLightestShade;
}