feat: gRPC request stubs (#6196)

* feat: grpc request stubs

* mark changed lines

* add tests

* fix infinite recursion

* replace empty body

* catch Type objects

* button to replace stubs

* flatten grpc editor

* remove grpc-editor

* trigger setvalue

* rename mocks to example

---------

Co-authored-by: jackkav <jackkav@gmail.com>
Co-authored-by: James Gatz <jamesgatzos@gmail.com>
This commit is contained in:
Nick Graham 2023-08-01 03:53:17 -05:00 committed by GitHub
parent 96ae43700e
commit 97760348e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 522 additions and 209 deletions

View File

@ -0,0 +1,122 @@
import { it } from '@jest/globals';
import { parse } from 'protobufjs';
import { mockRequestMethods } from '../automock';
it('mocks simple requests', () => {
const parsed = parse(`
syntax = "proto3";
message FooRequest {
string foo = 1;
}
message FooResponse {
string foo = 1;
}
service FooService {
rpc Foo (FooRequest) returns (FooResponse);
}`);
const service = parsed.root.lookupService('FooService');
const mocked = mockRequestMethods(service);
const plain = mocked['Foo']().plain;
expect(plain).toStrictEqual({
foo: 'Hello',
});
});
it('mocks requests with nested objects', () => {
const parsed = parse(`
syntax = "proto3";
message BarBarObject {
int32 one = 1;
}
message BarObject {
BarBarObject fuzz = 1;
}
message FooRequest {
BarObject bar = 2;
}
message FooResponse {
string foo = 1;
}
service FooService {
rpc Foo (FooRequest) returns (FooResponse);
}`);
const service = parsed.root.lookupService('FooService');
const mocked = mockRequestMethods(service);
const plain = mocked['Foo']().plain;
expect(plain).toStrictEqual({
bar: {
fuzz: {
one: 10,
},
},
});
});
it('mocks requests with enums', () => {
const parsed = parse(`
syntax = "proto3";
enum MyEnum {
MYENUM_UNSPECIFIED = 0;
MYENUM_A = 1;
MYENUM_B = 2;
}
message FooRequest {
MyEnum enum = 1;
}
message FooResponse {
string foo = 1;
}
service FooService {
rpc Foo (FooRequest) returns (FooResponse);
}`);
const service = parsed.root.lookupService('FooService');
const mocked = mockRequestMethods(service);
const plain = mocked['Foo']().plain;
expect(plain).toStrictEqual({
enum: 0,
});
});
it('mocks requests with repeated values', () => {
const parsed = parse(`
syntax = "proto3";
message FooRequest {
repeated string foo = 1;
}
message FooResponse {
string foo = 1;
}
service FooService {
rpc Foo (FooRequest) returns (FooResponse);
}`);
const service = parsed.root.lookupService('FooService');
const mocked = mockRequestMethods(service);
const plain = mocked['Foo']().plain;
expect(plain).toStrictEqual({
foo: ['Hello'],
});
});

View File

@ -41,6 +41,9 @@ describe('loadMethodsFromReflection', () => {
expect(methods).toStrictEqual([{
type: 'unary',
fullPath: '/FooService/Foo',
example: {
foo: 'Hello',
},
}]);
});
});
@ -76,6 +79,9 @@ describe('loadMethodsFromReflection', () => {
expect(methods).toStrictEqual([{
type: 'unary',
fullPath: '/FooService/format',
example: {
foo: 'Hello',
},
}]);
});
});
@ -123,9 +129,15 @@ describe('loadMethodsFromReflection', () => {
expect(methods).toStrictEqual([{
type: 'unary',
fullPath: '/FooService/Foo',
example: {
foo: 'Hello',
},
}, {
type: 'unary',
fullPath: '/BarService/Bar',
example: {
bar: 'Hello',
},
}]);
});
});

View File

@ -0,0 +1,261 @@
// From https://github.com/bloomrpc/bloomrpc-mock/blob/master/src/automock.ts
// TODO simplify this and rename to generate example payload
import { Enum, Field, MapField, Message, OneOf, Service, Type } from 'protobufjs';
import { v4 } from 'uuid';
export interface MethodPayload {
plain: {[key: string]: any};
message: Message;
}
export interface ServiceMethodsPayload {
[name: string]: () => MethodPayload;
}
const enum MethodType {
request,
response
}
/**
* Mock method response
*/
export function mockResponseMethods(
service: Service,
mocks?: void | {},
) {
return mockMethodReturnType(
service,
MethodType.response,
mocks
);
}
/**
* Mock methods request
*/
export function mockRequestMethods(
service: Service,
mocks?: void | {},
) {
return mockMethodReturnType(
service,
MethodType.request,
mocks
);
}
function mockMethodReturnType(
service: Service,
type: MethodType,
mocks?: void | {},
): ServiceMethodsPayload {
const root = service.root;
const serviceMethods = service.methods;
return Object.keys(serviceMethods).reduce((methods: ServiceMethodsPayload, method: string) => {
const serviceMethod = serviceMethods[method];
const methodMessageType = type === MethodType.request
? serviceMethod.requestType
: serviceMethod.responseType;
const messageType = root.lookupType(methodMessageType);
methods[method] = () => {
let data = {};
if (!mocks) {
data = mockTypeFields(messageType, new StackDepth());
}
return { plain: data, message: messageType.fromObject(data) };
};
return methods;
}, {});
}
/**
* Mock a field type
*/
function mockTypeFields(type: Type, stackDepth: StackDepth): object {
if (stackDepth.incrementAndCheckIfOverMax(`$type.${type.name}`)) {
return {};
}
const fieldsData: { [key: string]: any } = {};
if (!type.fieldsArray) {
return fieldsData;
}
return type.fieldsArray.reduce((data, field) => {
const resolvedField = field.resolve();
if (resolvedField.parent !== resolvedField.resolvedType) {
if (resolvedField.repeated) {
data[resolvedField.name] = [mockField(resolvedField, stackDepth)];
} else {
data[resolvedField.name] = mockField(resolvedField, stackDepth);
}
}
return data;
}, fieldsData);
}
/**
* Mock enum
*/
function mockEnum(enumType: Enum): number {
const enumKey = Object.keys(enumType.values)[0];
return enumType.values[enumKey];
}
/**
* Mock a field
*/
function mockField(field: Field, stackDepth: StackDepth): any {
if (stackDepth.incrementAndCheckIfOverMax(`$field.${field.name}`)) {
return {};
}
if (field instanceof MapField) {
return mockMapField(field, stackDepth);
}
if (field.resolvedType instanceof Enum) {
return mockEnum(field.resolvedType);
}
if (isProtoType(field.resolvedType)) {
return mockTypeFields(field.resolvedType, stackDepth);
}
const mockPropertyValue = mockScalar(field.type, field.name);
if (mockPropertyValue === null) {
const resolvedField = field.resolve();
return mockField(resolvedField, stackDepth);
} else {
return mockPropertyValue;
}
}
function mockMapField(field: MapField, stackDepth: StackDepth): any {
let mockPropertyValue = null;
if (field.resolvedType === null) {
mockPropertyValue = mockScalar(field.type, field.name);
}
if (mockPropertyValue === null) {
const resolvedType = field.resolvedType;
if (resolvedType instanceof Type) {
if (resolvedType.oneofs) {
mockPropertyValue = pickOneOf(resolvedType.oneofsArray, stackDepth);
} else {
mockPropertyValue = mockTypeFields(resolvedType, stackDepth);
}
} else if (resolvedType instanceof Enum) {
mockPropertyValue = mockEnum(resolvedType);
} else if (resolvedType === null) {
mockPropertyValue = {};
}
}
return {
[mockScalar(field.keyType, field.name)]: mockPropertyValue,
};
}
function isProtoType(resolvedType: Enum | Type | null): resolvedType is Type {
if (!resolvedType) {
return false;
}
const fieldsArray: keyof Type = 'fieldsArray';
return resolvedType instanceof Type || (
fieldsArray in resolvedType && Array.isArray(resolvedType[fieldsArray])
);
}
function pickOneOf(oneofs: OneOf[], stackDepth: StackDepth) {
return oneofs.reduce((fields: {[key: string]: any}, oneOf) => {
fields[oneOf.name] = mockField(oneOf.fieldsArray[0], stackDepth);
return fields;
}, {});
}
function mockScalar(type: string, fieldName: string): any {
switch (type) {
case 'string':
return interpretMockViaFieldName(fieldName);
case 'number':
return 10;
case 'bool':
return true;
case 'int32':
return 10;
case 'int64':
return 20;
case 'uint32':
return 100;
case 'uint64':
return 100;
case 'sint32':
return 100;
case 'sint64':
return 1200;
case 'fixed32':
return 1400;
case 'fixed64':
return 1500;
case 'sfixed32':
return 1600;
case 'sfixed64':
return 1700;
case 'double':
return 1.4;
case 'float':
return 1.1;
case 'bytes':
return Buffer.from([0xa1, 0xb2, 0xc3]);
default:
return null;
}
}
/**
* Tries to guess a mock value from the field name.
* Default Hello.
*/
function interpretMockViaFieldName(fieldName: string): string {
const fieldNameLower = fieldName.toLowerCase();
if (fieldNameLower.startsWith('id') || fieldNameLower.endsWith('id')) {
return v4();
}
return 'Hello';
}
class StackDepth {
private readonly depths: { [type: string]: number };
readonly maxStackSize: number;
constructor(maxStackSize = 3) {
this.depths = {};
this.maxStackSize = maxStackSize;
}
incrementAndCheckIfOverMax(key: string): boolean {
if (this.depths[key] > this.maxStackSize) {
return true;
}
if (!this.depths[key]) {
this.depths[key] = 0;
}
this.depths[key]++;
return false;
}
}

View File

@ -1,9 +1,7 @@
import { Call, ClientDuplexStream, ClientReadableStream, ServiceError, StatusObject } from '@grpc/grpc-js';
import { credentials, makeGenericClientConstructor, Metadata, status } from '@grpc/grpc-js';
import { Call, ClientDuplexStream, ClientReadableStream, credentials, makeGenericClientConstructor, Metadata, ServiceError, status, StatusObject } from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import { AnyDefinition, EnumTypeDefinition, MessageTypeDefinition, PackageDefinition, ServiceDefinition } from '@grpc/proto-loader';
import electron, { ipcMain } from 'electron';
import { IpcMainEvent } from 'electron';
import electron, { ipcMain, IpcMainEvent } from 'electron';
import * as grpcReflection from 'grpc-reflection-js';
import type { RenderedGrpcRequest, RenderedGrpcRequestBody } from '../../common/render';
@ -12,6 +10,7 @@ import type { GrpcRequest, GrpcRequestHeader } from '../../models/grpc-request';
import { parseGrpcUrl } from '../../network/grpc/parse-grpc-url';
import { writeProtoFile } from '../../network/grpc/write-proto-file';
import { invariant } from '../../utils/invariant';
import { mockRequestMethods } from './automock';
const grpcCalls = new Map<string, Call>();
export interface GrpcIpcRequestParams {
@ -74,6 +73,7 @@ interface MethodDefs {
responseStream: boolean;
requestSerialize: (value: any) => Buffer;
responseDeserialize: (value: Buffer) => any;
example?: Record<string, any>;
}
const getMethodsFromReflection = async (host: string, metadata: GrpcRequestHeader[]): Promise<MethodDefs[]> => {
try {
@ -83,18 +83,29 @@ const getMethodsFromReflection = async (host: string, metadata: GrpcRequestHeade
grpcOptions,
filterDisabledMetaData(metadata)
);
const services = await client.listServices() as string[];
const services = await client.listServices();
const methodsPromises = services.map(async service => {
const fileContainingSymbol = await client.fileContainingSymbol(service);
const fullService = fileContainingSymbol.lookupService(service);
const mockedRequestMethods = mockRequestMethods(fullService);
const descriptorMessage = fileContainingSymbol.toDescriptor('proto3');
const packageDefinition = protoLoader.loadFileDescriptorSetFromObject(descriptorMessage, {});
const tryToGetMethods = () => {
try {
console.log('[grpc] loading service from reflection:', service);
const packageDefinition = protoLoader.loadFileDescriptorSetFromObject(descriptorMessage, {});
const serviceDefinition = asServiceDefinition(packageDefinition[service]);
invariant(serviceDefinition, `'${service}' was not a valid ServiceDefinition`);
const serviceMethods = Object.values(serviceDefinition);
return serviceMethods;
return serviceMethods.map(m => {
const methodName = Object.keys(mockedRequestMethods).find(name => m.path.endsWith(`/${name}`));
if (!methodName) {
return m;
}
return {
...m,
example: mockedRequestMethods[methodName]().plain,
};
});
} catch (e) {
console.error(e);
return [];
@ -114,11 +125,13 @@ export const loadMethodsFromReflection = async (options: { url: string; metadata
return methods.map(method => ({
type: getMethodType(method),
fullPath: method.path,
example: method.example,
}));
};
export interface GrpcMethodInfo {
type: GrpcMethodType;
fullPath: string;
example?: Record<string, any>;
}
export const getMethodType = ({ requestStream, responseStream }: any): GrpcMethodType => {
if (requestStream && responseStream) {

View File

@ -1,51 +0,0 @@
import React, { FunctionComponent } from 'react';
import { useSelector } from 'react-redux';
import * as models from '../../../models';
import type { Response } from '../../../models/response';
import { useRequestMetaPatcher } from '../../hooks/use-request';
import { selectActiveResponse } from '../../redux/selectors';
import { CodeEditor } from '../codemirror/code-editor';
interface Props {
content?: string;
handleChange?: (arg0: string) => void;
readOnly?: boolean;
}
export const GRPCEditor: FunctionComponent<Props> = ({
content,
handleChange,
readOnly,
}) => {
const response = useSelector(selectActiveResponse) as Response | null;
const patchRequestMeta = useRequestMetaPatcher();
const handleSetFilter = async (responseFilter: string) => {
if (!response) {
return;
}
const requestId = response.parentId;
await patchRequestMeta(requestId, { responseFilter });
const meta = await models.requestMeta.getByParentId(requestId);
if (!meta) {
return;
}
const responseFilterHistory = meta.responseFilterHistory.slice(0, 10);
// Already in history or empty?
if (!responseFilter || responseFilterHistory.includes(responseFilter)) {
return;
}
responseFilterHistory.unshift(responseFilter);
patchRequestMeta(requestId, { responseFilterHistory });
};
return (<CodeEditor
defaultValue={content}
onChange={handleChange}
mode="application/json"
enableNunjucks
readOnly={readOnly}
autoPrettify={readOnly}
showPrettifyButton={!readOnly}
updateFilter={handleSetFilter}
/>);
};

View File

@ -1,4 +1,4 @@
import React, { FunctionComponent, useState } from 'react';
import React, { FunctionComponent, useRef, useState } from 'react';
import { useParams, useRouteLoaderData } from 'react-router-dom';
import { useMount } from 'react-use';
import styled from 'styled-components';
@ -18,6 +18,7 @@ import { RequestLoaderData } from '../../routes/request';
import { WorkspaceLoaderData } from '../../routes/workspace';
import { PanelContainer, TabItem, Tabs } from '../base/tabs';
import { GrpcSendButton } from '../buttons/grpc-send-button';
import { CodeEditor, CodeEditorHandle } from '../codemirror/code-editor';
import { OneLineEditor } from '../codemirror/one-line-editor';
import { GrpcMethodDropdown } from '../dropdowns/grpc-method-dropdown/grpc-method-dropdown';
import { ErrorBoundary } from '../error-boundary';
@ -30,7 +31,6 @@ import { RequestRenderErrorModal } from '../modals/request-render-error-modal';
import { SvgIcon } from '../svg-icon';
import { Button } from '../themed-button';
import { Tooltip } from '../tooltip';
import { GrpcTabbedMessages } from '../viewers/grpc-tabbed-messages';
import { EmptyStatePane } from './empty-state-pane';
import { Pane, PaneBody, PaneHeader } from './pane';
interface Props {
@ -85,7 +85,7 @@ export const GrpcRequestPane: FunctionComponent<Props> = ({
const methods = await window.main.grpc.loadMethods(activeRequest.protoFileId);
setGrpcState({ ...grpcState, methods });
});
const editorRef = useRef<CodeEditorHandle>(null);
const gitVersion = useGitVCSVersion();
const activeRequestSyncVersion = useActiveRequestSyncVCSVersion();
const { workspaceId, requestId } = useParams() as { workspaceId: string; requestId: string };
@ -175,6 +175,20 @@ export const GrpcRequestPane: FunctionComponent<Props> = ({
});
}}
/>
<Button
variant="text"
data-testid="button-use-request-stubs"
disabled={!method?.example}
onClick={() => {
if (editorRef.current && method?.example) {
editorRef.current.setValue(JSON.stringify(method.example, null, 2));
}
}}
>
<Tooltip message="Click to replace body with an example" position="bottom" delay={500}>
<i className="fa fa-code" />
</Tooltip>
</Button>
<Button
variant="text"
data-testid="button-server-reflection"
@ -217,35 +231,67 @@ export const GrpcRequestPane: FunctionComponent<Props> = ({
{methodType && (
<Tabs aria-label="Grpc request pane tabs">
<TabItem key="method-type" title={GrpcMethodTypeName[methodType]}>
<GrpcTabbedMessages
uniquenessKey={uniquenessKey}
tabNamePrefix="Stream"
messages={requestMessages}
bodyText={activeRequest.body.text}
handleBodyChange={text => patchRequest(requestId, { body: { text } })}
showActions={running && canClientStream(methodType)}
handleStream={async () => {
const requestBody = await getRenderedGrpcRequestMessage({
request: activeRequest,
environmentId,
purpose: RENDER_PURPOSE_SEND,
});
const preparedMessage = {
body: requestBody,
requestId,
};
window.main.grpc.sendMessage(preparedMessage);
setGrpcState({
...grpcState, requestMessages: [...requestMessages, {
id: generateId(),
text: preparedMessage.body.text || '',
created: Date.now(),
}],
});
}}
handleCommit={() => window.main.grpc.commit(requestId)}
/>
<>
{running && canClientStream(methodType) && (
<ActionButtonsContainer>
<button
className='btn btn--compact btn--clicky-small margin-left-sm bg-default'
onClick={async () => {
const requestBody = await getRenderedGrpcRequestMessage({
request: activeRequest,
environmentId,
purpose: RENDER_PURPOSE_SEND,
});
const preparedMessage = {
body: requestBody,
requestId,
};
window.main.grpc.sendMessage(preparedMessage);
setGrpcState({
...grpcState, requestMessages: [...requestMessages, {
id: generateId(),
text: preparedMessage.body.text || '',
created: Date.now(),
}],
});
}}
>
Stream <i className='fa fa-plus' />
</button>
<button
className='btn btn--compact btn--clicky-small margin-left-sm bg-surprise'
onClick={() => window.main.grpc.commit(requestId)}
>
Commit <i className='fa fa-arrow-right' />
</button>
</ActionButtonsContainer>
)}
<Tabs key={uniquenessKey} aria-label="Grpc tabbed messages tabs" isNested>
{[
<TabItem key="body" title="Body">
<CodeEditor
ref={editorRef}
defaultValue={activeRequest.body.text}
onChange={text => patchRequest(requestId, { body: { text } })}
mode="application/json"
enableNunjucks
showPrettifyButton={true}
/>
</TabItem>,
...requestMessages.sort((a, b) => a.created - b.created).map((m, index) => (
<TabItem key={m.id} title={`Stream ${index + 1}`}>
<CodeEditor
defaultValue={m.text}
mode="application/json"
enableNunjucks
readOnly
autoPrettify
/>
</TabItem>
)),
]}
</Tabs>
</>
</TabItem>
<TabItem key="headers" title="Headers">
<PanelContainer className="tall wide">
@ -292,3 +338,12 @@ export const GrpcRequestPane: FunctionComponent<Props> = ({
</>
);
};
const ActionButtonsContainer = styled.div({
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
boxSizing: 'border-box',
height: 'var(--line-height-sm)',
borderBottom: '1px solid var(--hl-lg)',
padding: 3,
});

View File

@ -1,46 +1,40 @@
import React, { FunctionComponent } from 'react';
import { useRouteLoaderData } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { useActiveRequestSyncVCSVersion, useGitVCSVersion } from '../../hooks/use-vcs-version';
import { GrpcRequestState } from '../../routes/debug';
import { WorkspaceLoaderData } from '../../routes/workspace';
import { TabItem, Tabs } from '../base/tabs';
import { CodeEditor } from '../codemirror/code-editor';
import { GrpcStatusTag } from '../tags/grpc-status-tag';
import { GrpcTabbedMessages } from '../viewers/grpc-tabbed-messages';
import { Pane, PaneBody, PaneHeader } from './pane';
interface Props {
grpcState: GrpcRequestState;
}
export const GrpcResponsePane: FunctionComponent<Props> = ({ grpcState }) => {
const gitVersion = useGitVCSVersion();
const activeRequestSyncVersion = useActiveRequestSyncVCSVersion();
const {
activeEnvironment,
} = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData;
const { requestId } = useParams() as { requestId: string };
// Force re-render when we switch requests, the environment gets modified, or the (Git|Sync)VCS version changes
const uniquenessKey = `${activeEnvironment.modified}::${requestId}::${gitVersion}::${activeRequestSyncVersion}`;
const { responseMessages, status, error } = grpcState;
export const GrpcResponsePane: FunctionComponent<Props> = ({ grpcState: { running, responseMessages, status, error } }) => {
return (
<Pane type="response">
<PaneHeader className="row-spaced">
<div className="no-wrap scrollable scrollable--no-bars pad-left">
{grpcState.running && <i className='fa fa-refresh fa-spin margin-right-sm' />}
{running && <i className='fa fa-refresh fa-spin margin-right-sm' />}
{status && <GrpcStatusTag statusCode={status.code} statusMessage={status.details} />}
{!status && error && <GrpcStatusTag statusMessage={error.message} />}
</div>
</PaneHeader>
<PaneBody>
{!!responseMessages.length && (
<GrpcTabbedMessages
uniquenessKey={uniquenessKey}
tabNamePrefix="Response"
messages={responseMessages}
/>
)}
<PaneBody >
{responseMessages.length
? (<Tabs aria-label="Grpc tabbed messages tabs" isNested>
{responseMessages.sort((a, b) => a.created - b.created).map((m, index) => (
<TabItem key={m.id} title={`Response ${index + 1}`}>
<CodeEditor
defaultValue={m.text}
mode="application/json"
enableNunjucks
readOnly
autoPrettify
/>
</TabItem>))}
</Tabs>)
: null
}
</PaneBody>
</Pane>
);

View File

@ -1,93 +0,0 @@
import React, { FunctionComponent } from 'react';
import styled from 'styled-components';
import { HandleGetRenderContext, HandleRender } from '../../../common/render';
import { TabItem, Tabs } from '../base/tabs';
import { GRPCEditor } from '../editors/grpc-editor';
interface Message {
id: string;
created: number;
text: string;
}
interface Props {
messages?: Message[];
tabNamePrefix: 'Stream' | 'Response';
bodyText?: string;
uniquenessKey: string;
handleBodyChange?: (arg0: string) => void;
handleStream?: () => void;
handleCommit?: () => void;
showActions?: boolean;
handleRender?: HandleRender;
handleGetRenderContext?: HandleGetRenderContext;
}
const ActionButtonsContainer = styled.div({
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
boxSizing: 'border-box',
height: 'var(--line-height-sm)',
borderBottom: '1px solid var(--hl-lg)',
padding: 3,
});
export const GrpcTabbedMessages: FunctionComponent<Props> = ({
showActions,
bodyText,
messages,
tabNamePrefix,
handleBodyChange,
handleCommit,
handleStream,
uniquenessKey,
}) => {
const shouldShowBody = !!handleBodyChange;
const orderedMessages = messages?.sort((a, b) => a.created - b.created) || [];
const tabItems = [];
if (shouldShowBody) {
tabItems.push(
<TabItem key="body" title="Body">
<GRPCEditor content={bodyText} handleChange={handleBodyChange} />
</TabItem>
);
}
orderedMessages.map((m, index) => tabItems.push(
<TabItem key={m.id} title={`${tabNamePrefix} ${index + 1}`}>
<GRPCEditor content={m.text} readOnly />
</TabItem>
));
return (
<>
{showActions && (
<ActionButtonsContainer>
{handleStream && (
<button
className='btn btn--compact btn--clicky-small margin-left-sm bg-default'
onClick={handleStream}
>
Stream <i className='fa fa-plus' />
</button>
)}
{handleCommit && (
<button
className='btn btn--compact btn--clicky-small margin-left-sm bg-surprise'
onClick={handleCommit}
>
Commit <i className='fa fa-arrow-right' />
</button>
)}
</ActionButtonsContainer>
)}
<Tabs key={uniquenessKey} aria-label="Grpc tabbed messages tabs" isNested>
{tabItems}
</Tabs>
</>
);
};