Convert plugin UI to Sandy
Summary: Changelog: Updated Network plugin to Sandy UI, including several UI improvements Converted UI to Sandy, and some minor code cleanups Moved all mock related logic to its own dir Fixes https://github.com/facebook/flipper/issues/2267 Reviewed By: passy Differential Revision: D27966606 fbshipit-source-id: a64e20276d7f0966ce7a95b22557762a32c184cd
This commit is contained in:
committed by
Facebook GitHub Bot
parent
84d65b1a77
commit
fc4a08eb55
@@ -7,51 +7,29 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Request, Header, Insights, RetryInsights} from './types';
|
||||
|
||||
import {
|
||||
Component,
|
||||
FlexColumn,
|
||||
ManagedTable,
|
||||
ManagedDataInspector,
|
||||
Text,
|
||||
Panel,
|
||||
Select,
|
||||
styled,
|
||||
colors,
|
||||
SmallText,
|
||||
} from 'flipper';
|
||||
import {decodeBody, getHeaderValue} from './utils';
|
||||
import {formatBytes, BodyOptions} from './index';
|
||||
import React from 'react';
|
||||
|
||||
import {Component} from 'react';
|
||||
import querystring from 'querystring';
|
||||
import xmlBeautifier from 'xml-beautifier';
|
||||
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
|
||||
import {Base64} from 'js-base64';
|
||||
|
||||
const WrappingText = styled(Text)({
|
||||
wordWrap: 'break-word',
|
||||
width: '100%',
|
||||
lineHeight: '125%',
|
||||
padding: '3px 0',
|
||||
});
|
||||
import {
|
||||
DataInspector,
|
||||
Layout,
|
||||
Panel,
|
||||
styled,
|
||||
theme,
|
||||
CodeBlock,
|
||||
} from 'flipper-plugin';
|
||||
import {Select, Typography} from 'antd';
|
||||
|
||||
const KeyValueColumnSizes = {
|
||||
key: '30%',
|
||||
value: 'flex',
|
||||
};
|
||||
import {formatBytes, decodeBody, getHeaderValue} from './utils';
|
||||
import {Request, Header, Insights, RetryInsights} from './types';
|
||||
import {BodyOptions} from './index';
|
||||
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
|
||||
import {KeyValueItem, KeyValueTable} from './KeyValueTable';
|
||||
|
||||
const KeyValueColumns = {
|
||||
key: {
|
||||
value: 'Key',
|
||||
resizable: false,
|
||||
},
|
||||
value: {
|
||||
value: 'Value',
|
||||
resizable: false,
|
||||
},
|
||||
};
|
||||
const {Text} = Typography;
|
||||
|
||||
type RequestDetailsProps = {
|
||||
request: Request;
|
||||
@@ -59,52 +37,23 @@ type RequestDetailsProps = {
|
||||
onSelectFormat: (bodyFormat: string) => void;
|
||||
};
|
||||
export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||
static Container = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
urlColumns = (url: URL) => {
|
||||
return [
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Full URL</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.href}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.href,
|
||||
key: 'url',
|
||||
key: 'Full URL',
|
||||
value: url.href,
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Host</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.host}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.host,
|
||||
key: 'host',
|
||||
key: 'Host',
|
||||
value: url.host,
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Path</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.pathname}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.pathname,
|
||||
key: 'path',
|
||||
key: 'Path',
|
||||
value: url.pathname,
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Query String</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.search}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.search,
|
||||
key: 'query',
|
||||
key: 'Query String',
|
||||
value: url.search,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -113,51 +62,28 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||
const {request, bodyFormat, onSelectFormat} = this.props;
|
||||
const url = new URL(request.url);
|
||||
|
||||
const formattedText = bodyFormat == BodyOptions.formatted;
|
||||
const formattedText = bodyFormat == 'formatted';
|
||||
|
||||
return (
|
||||
<RequestDetails.Container>
|
||||
<Panel
|
||||
key="request"
|
||||
heading={'Request'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={this.urlColumns(url)}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
<>
|
||||
<Panel key="request" title={'Request'}>
|
||||
<KeyValueTable items={this.urlColumns(url)} />
|
||||
</Panel>
|
||||
|
||||
{url.search ? (
|
||||
<Panel
|
||||
heading={'Request Query Parameters'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<Panel title={'Request Query Parameters'}>
|
||||
<QueryInspector queryParams={url.searchParams} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{request.requestHeaders.length > 0 ? (
|
||||
<Panel
|
||||
key="headers"
|
||||
heading={'Request Headers'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<Panel key="headers" title={'Request Headers'}>
|
||||
<HeaderInspector headers={request.requestHeaders} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{request.requestData != null ? (
|
||||
<Panel
|
||||
key="requestData"
|
||||
heading={'Request Body'}
|
||||
floating={false}
|
||||
padded={!formattedText}>
|
||||
<Panel key="requestData" title={'Request Body'} pad>
|
||||
<RequestBodyInspector
|
||||
formattedText={formattedText}
|
||||
request={request}
|
||||
@@ -169,21 +95,18 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||
{request.responseHeaders?.length ? (
|
||||
<Panel
|
||||
key={'responseheaders'}
|
||||
heading={`Response Headers${
|
||||
title={`Response Headers${
|
||||
request.responseIsMock ? ' (Mocked)' : ''
|
||||
}`}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
}`}>
|
||||
<HeaderInspector headers={request.responseHeaders} />
|
||||
</Panel>
|
||||
) : null}
|
||||
<Panel
|
||||
key={'responsebody'}
|
||||
heading={`Response Body${
|
||||
title={`Response Body${
|
||||
request.responseIsMock ? ' (Mocked)' : ''
|
||||
}`}
|
||||
floating={false}
|
||||
padded={!formattedText}>
|
||||
pad>
|
||||
<ResponseBodyInspector
|
||||
formattedText={formattedText}
|
||||
request={request}
|
||||
@@ -191,64 +114,34 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||
</Panel>
|
||||
</>
|
||||
) : null}
|
||||
<Panel
|
||||
key="options"
|
||||
heading={'Options'}
|
||||
floating={false}
|
||||
collapsed={true}>
|
||||
<Panel key="options" title={'Options'} collapsed pad>
|
||||
<Text>Body formatting:</Text>
|
||||
<Select
|
||||
grow
|
||||
label="Body"
|
||||
selected={bodyFormat}
|
||||
value={bodyFormat}
|
||||
onChange={onSelectFormat}
|
||||
options={BodyOptions}
|
||||
/>
|
||||
</Panel>
|
||||
{request.insights ? (
|
||||
<Panel
|
||||
key="insights"
|
||||
heading={'Insights'}
|
||||
floating={false}
|
||||
collapsed={true}>
|
||||
<Panel key="insights" title={'Insights'} collapsed>
|
||||
<InsightsInspector insights={request.insights} />
|
||||
</Panel>
|
||||
) : null}
|
||||
</RequestDetails.Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QueryInspector extends Component<{queryParams: URLSearchParams}> {
|
||||
render() {
|
||||
const {queryParams} = this.props;
|
||||
|
||||
const rows: any = [];
|
||||
queryParams.forEach((value: string, key: string) => {
|
||||
const rows: KeyValueItem[] = [];
|
||||
this.props.queryParams.forEach((value: string, key: string) => {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{key}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{value}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: value,
|
||||
key: key,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
return rows.length > 0 ? <KeyValueTable items={rows} /> : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,43 +165,15 @@ class HeaderInspector extends Component<
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const rows: any = [];
|
||||
Array.from(computedHeaders.entries())
|
||||
const rows = Array.from(computedHeaders.entries())
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] == b[0] ? 0 : 1))
|
||||
.forEach(([key, value]) => {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{key}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{value}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: value,
|
||||
key,
|
||||
});
|
||||
});
|
||||
|
||||
.map(([key, value]) => ({key, value}));
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
<KeyValueTable items={this.props.headers} />
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
const BodyContainer = styled.div({
|
||||
paddingTop: 10,
|
||||
paddingBottom: 20,
|
||||
});
|
||||
|
||||
type BodyFormatter = {
|
||||
formatRequest?: (request: Request) => any;
|
||||
formatResponse?: (request: Request) => any;
|
||||
@@ -330,12 +195,12 @@ class RequestBodyInspector extends Component<{
|
||||
const component = formatter.formatRequest(request);
|
||||
if (component) {
|
||||
return (
|
||||
<BodyContainer>
|
||||
<Layout.Container gap>
|
||||
{component}
|
||||
<FormattedBy>
|
||||
Formatted by {formatter.constructor.name}
|
||||
</FormattedBy>
|
||||
</BodyContainer>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -366,12 +231,12 @@ class ResponseBodyInspector extends Component<{
|
||||
const component = formatter.formatResponse(request);
|
||||
if (component) {
|
||||
return (
|
||||
<BodyContainer>
|
||||
<Layout.Container gap>
|
||||
{component}
|
||||
<FormattedBy>
|
||||
Formatted by {formatter.constructor.name}
|
||||
</FormattedBy>
|
||||
</BodyContainer>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -386,17 +251,18 @@ class ResponseBodyInspector extends Component<{
|
||||
}
|
||||
}
|
||||
|
||||
const FormattedBy = styled(SmallText)({
|
||||
const FormattedBy = styled(Text)({
|
||||
marginTop: 8,
|
||||
fontSize: '0.7em',
|
||||
textAlign: 'center',
|
||||
display: 'block',
|
||||
color: theme.disabledColor,
|
||||
});
|
||||
|
||||
const Empty = () => (
|
||||
<BodyContainer>
|
||||
<Layout.Container pad>
|
||||
<Text>(empty)</Text>
|
||||
</BodyContainer>
|
||||
</Layout.Container>
|
||||
);
|
||||
|
||||
function getRequestData(request: Request) {
|
||||
@@ -420,29 +286,19 @@ function renderRawBody(request: Request, mode: 'request' | 'response') {
|
||||
mode === 'request' ? getRequestData(request) : getResponseData(request),
|
||||
);
|
||||
return (
|
||||
<BodyContainer>
|
||||
<Layout.Container gap>
|
||||
{decoded ? (
|
||||
<Text selectable wordWrap="break-word">
|
||||
{decoded}
|
||||
</Text>
|
||||
<CodeBlock>{decoded}</CodeBlock>
|
||||
) : (
|
||||
<>
|
||||
<FormattedBy>(Failed to decode)</FormattedBy>
|
||||
<Text selectable wordWrap="break-word">
|
||||
{data}
|
||||
</Text>
|
||||
<CodeBlock>{data}</CodeBlock>
|
||||
</>
|
||||
)}
|
||||
</BodyContainer>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
|
||||
const MediaContainer = styled(FlexColumn)({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
type ImageWithSizeProps = {
|
||||
src: string;
|
||||
};
|
||||
@@ -459,13 +315,8 @@ class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
|
||||
marginBottom: 10,
|
||||
});
|
||||
|
||||
static Text = styled(Text)({
|
||||
color: colors.dark70,
|
||||
fontSize: 14,
|
||||
});
|
||||
|
||||
constructor(props: ImageWithSizeProps, context: any) {
|
||||
super(props, context);
|
||||
constructor(props: ImageWithSizeProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
@@ -487,12 +338,12 @@ class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<MediaContainer>
|
||||
<Layout.Container center>
|
||||
<ImageWithSize.Image src={this.props.src} />
|
||||
<ImageWithSize.Text>
|
||||
<Text type="secondary">
|
||||
{this.state.width} x {this.state.height}
|
||||
</ImageWithSize.Text>
|
||||
</MediaContainer>
|
||||
</Text>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -528,44 +379,36 @@ class VideoFormatter {
|
||||
const contentType = getHeaderValue(request.responseHeaders, 'content-type');
|
||||
if (contentType.startsWith('video/')) {
|
||||
return (
|
||||
<MediaContainer>
|
||||
<Layout.Container center>
|
||||
<VideoFormatter.Video controls={true}>
|
||||
<source src={request.url} type={contentType} />
|
||||
</VideoFormatter.Video>
|
||||
</MediaContainer>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class JSONText extends Component<{children: any}> {
|
||||
static NoScrollbarText = styled(Text)({
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
render() {
|
||||
const jsonObject = this.props.children;
|
||||
return (
|
||||
<JSONText.NoScrollbarText code whiteSpace="pre" selectable>
|
||||
<CodeBlock>
|
||||
{JSON.stringify(jsonObject, null, 2)}
|
||||
{'\n'}
|
||||
</JSONText.NoScrollbarText>
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class XMLText extends Component<{body: any}> {
|
||||
static NoScrollbarText = styled(Text)({
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
render() {
|
||||
const xmlPretty = xmlBeautifier(this.props.body);
|
||||
return (
|
||||
<XMLText.NoScrollbarText code whiteSpace="pre" selectable>
|
||||
<CodeBlock>
|
||||
{xmlPretty}
|
||||
{'\n'}
|
||||
</XMLText.NoScrollbarText>
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -652,20 +495,14 @@ class JSONFormatter {
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
return <DataInspector collapsed expandRoot data={data} />;
|
||||
} catch (SyntaxError) {
|
||||
// Multiple top level JSON roots, map them one by one
|
||||
const roots = body.split('\n');
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
<DataInspector
|
||||
collapsed
|
||||
expandRoot
|
||||
data={roots.map((json) => JSON.parse(json))}
|
||||
/>
|
||||
);
|
||||
@@ -681,7 +518,7 @@ class LogEventFormatter {
|
||||
if (typeof data.message === 'string') {
|
||||
data.message = JSON.parse(data.message);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
return <DataInspector expandRoot data={data} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -693,7 +530,7 @@ class GraphQLBatchFormatter {
|
||||
if (typeof data.queries === 'string') {
|
||||
data.queries = JSON.parse(data.queries);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
return <DataInspector expandRoot data={data} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -717,10 +554,10 @@ class GraphQLFormatter {
|
||||
const requestStartMs = serverMetadata['request_start_time_ms'];
|
||||
const timeAtFlushMs = serverMetadata['time_at_flush_ms'];
|
||||
return (
|
||||
<WrappingText>
|
||||
<Text>
|
||||
{'Server wall time for initial response (ms): ' +
|
||||
(timeAtFlushMs - requestStartMs)}
|
||||
</WrappingText>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
formatRequest(request: Request) {
|
||||
@@ -736,7 +573,7 @@ class GraphQLFormatter {
|
||||
if (typeof data.query_params === 'string') {
|
||||
data.query_params = JSON.parse(data.query_params);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
return <DataInspector expandRoot data={data} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,11 +597,7 @@ class GraphQLFormatter {
|
||||
return (
|
||||
<div>
|
||||
{this.parsedServerTimeForFirstFlush(data)}
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={data}
|
||||
/>
|
||||
<DataInspector collapsed expandRoot data={data} />
|
||||
</div>
|
||||
);
|
||||
} catch (SyntaxError) {
|
||||
@@ -776,11 +609,7 @@ class GraphQLFormatter {
|
||||
return (
|
||||
<div>
|
||||
{this.parsedServerTimeForFirstFlush(parsedResponses)}
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={parsedResponses}
|
||||
/>
|
||||
<DataInspector collapsed expandRoot data={parsedResponses} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -796,12 +625,7 @@ class FormUrlencodedFormatter {
|
||||
if (!decoded) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
expandRoot={true}
|
||||
data={querystring.parse(decoded)}
|
||||
/>
|
||||
);
|
||||
return <DataInspector expandRoot data={querystring.parse(decoded)} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -921,13 +745,13 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
||||
return `${formatBytes(value)}/sec`;
|
||||
}
|
||||
|
||||
formatRetries(retry: RetryInsights): string {
|
||||
formatRetries = (retry: RetryInsights): string => {
|
||||
const timesWord = retry.limit === 1 ? 'time' : 'times';
|
||||
|
||||
return `${this.formatTime(retry.timeSpent)} (${
|
||||
retry.count
|
||||
} ${timesWord} out of ${retry.limit})`;
|
||||
}
|
||||
};
|
||||
|
||||
buildRow<T>(
|
||||
name: string,
|
||||
@@ -936,16 +760,8 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
||||
): any {
|
||||
return value
|
||||
? {
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{name}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{formatter(value)}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: () => `${name}: ${formatter(value)}`,
|
||||
key: name,
|
||||
value: formatter(value),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -955,7 +771,7 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
||||
const {buildRow, formatTime, formatSpeed, formatRetries} = this;
|
||||
|
||||
const rows = [
|
||||
buildRow('Retries', insights.retries, formatRetries.bind(this)),
|
||||
buildRow('Retries', insights.retries, formatRetries),
|
||||
buildRow('DNS lookup time', insights.dnsLookupTime, formatTime),
|
||||
buildRow('Connect time', insights.connectTime, formatTime),
|
||||
buildRow('SSL handshake time', insights.sslHandshakeTime, formatTime),
|
||||
@@ -968,16 +784,6 @@ class InsightsInspector extends Component<{insights: Insights}> {
|
||||
buildRow('Transfer speed', insights.transferSpeed, formatSpeed),
|
||||
].filter((r) => r != null);
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
return rows.length > 0 ? <KeyValueTable items={rows} /> : null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user