react-saasify
Version:
React components for Saasify web clients.
511 lines (453 loc) • 13.8 kB
JavaScript
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import theme from 'lib/theme'
import mem from 'mem'
import isDeepEqual from 'fast-deep-equal'
import copyTextToClipboard from 'copy-text-to-clipboard'
import mediumZoom from 'medium-zoom'
import mime from 'mime-types'
import fileType from 'file-type'
import detectCsv from 'detect-csv'
import isHtml from 'is-html'
import download from 'downloadjs'
import { observer, inject } from 'mobx-react'
import { Button, Tooltip } from 'lib/antd'
import { Link } from 'react-router-dom'
import { CodeBlock } from '../CodeBlock'
import { ImageZoom } from '../ImageZoom'
import { ServiceForm } from '../ServiceForm'
import getServiceExamples from 'lib/get-service-examples'
import requestService from 'lib/request-service'
import { getUpgradeLink } from 'lib/upgrade'
import styles from './styles.module.css'
@inject('auth')
@observer
export class LiveServiceDemo extends Component {
static propTypes = {
deployment: PropTypes.object.isRequired,
service: PropTypes.object.isRequired,
auth: PropTypes.object.isRequired
}
state = {
selected: 'Playground',
copiedTextToClipboard: false,
running: null,
values: {}
}
_zoom = mediumZoom({
background: '#fff',
margin: 48
})
_onClickPlaygroundTabMem = mem(() => () =>
this._onClickTab({ label: 'Playground' })
)
_onClickTabMem = mem((i) => () => this._onClickTab(this._example.snippets[i]))
componentWillUnmount() {
if (this._copyTimeout) {
clearTimeout(this._copyTimeout)
this._copyTimeout = null
}
}
render() {
const { service, auth, deployment } = this.props
const { codeBlockOutputFlush } = deployment.saas.theme
const {
selected,
copiedTextToClipboard,
running,
output,
outputUrl,
outputContentType,
outputError,
downloaded,
hitRateLimit
} = this.state
try {
this._example = getServiceExamples(
service,
auth.consumer && auth.consumer.token,
{
method: service.httpMethod.toUpperCase()
}
)
} catch (err) {
console.warn('error generating service examples', service, err)
return null
}
if (!this._example) {
return null
}
let renderedOutput = null
if (outputError) {
renderedOutput = (
<div className={theme(styles, 'error')}>{outputError}</div>
)
} else if (hitRateLimit) {
renderedOutput = (
<div className={theme(styles, 'output__cta')}>
<div className={theme(styles, 'output__cta__message')}>
You've hit our public rate limit. To keep using the API, please
upgrade or try again later.
</div>
<div className={theme(styles, 'output__cta__button')}>
<Link to={getUpgradeLink({ auth, deployment })}>
<Button type='primary'>Upgrade</Button>
</Link>
</div>
</div>
)
} else if (outputContentType) {
if (downloaded) {
// TODO: gracefully handle other content-types
renderedOutput = (
<div className={theme(styles, 'message')}>
The resulting "{outputContentType}" file was downloaded.
</div>
)
} else if (outputUrl) {
if (outputContentType.startsWith('image/')) {
renderedOutput = (
<div className={theme(styles, 'img-wrapper')}>
<ImageZoom
src={outputUrl}
alt={this._example.name || 'Example output'}
zoom={this._zoom}
/>
</div>
)
}
} else if (output) {
// TODO: switch to use type-is package here
if (outputContentType.startsWith('text/')) {
renderedOutput = (
<CodeBlock
className={theme(styles, 'code')}
language={outputContentType.slice('text/'.length)}
value={output}
/>
)
} else if (outputContentType.startsWith('application/json')) {
let language
let value
if (typeof output === 'string') {
language = 'text'
value = output
} else {
language = 'json'
value = JSON.stringify(output, null, 2)
}
renderedOutput = (
<CodeBlock
className={theme(styles, 'code')}
language={language}
value={value}
/>
)
} else if (outputContentType.startsWith('image/')) {
const dataUrl = 'data:' + outputContentType + ';base64,' + output
renderedOutput = (
<div className={theme(styles, 'img-wrapper')}>
<ImageZoom
src={dataUrl}
alt={this._example.name || 'Example output'}
zoom={this._zoom}
/>
</div>
)
} else {
// TODO: gracefully handle other content-types
renderedOutput = (
<div className={theme(styles, 'message')}>
The resulting "{outputContentType}" file preview is not supported.
</div>
)
}
}
}
return (
<div className={theme(styles, 'live-service-demo')}>
<div className={theme(styles, 'tabs')}>
<div
className={theme(
styles,
'tab',
selected === 'Playground' && theme(styles, 'selected-tab')
)}
onClick={this._onClickPlaygroundTabMem()}
>
Playground
</div>
{this._example.snippets.map((l, i) => (
<div
className={theme(
styles,
'tab',
selected === l.label && theme(styles, 'selected-tab')
)}
key={i}
onClick={this._onClickTabMem(i)}
>
{l.label}
</div>
))}
</div>
<div className={theme(styles, 'tab-content')}>
<div
className={theme(
styles,
'tab-pane',
selected === 'Playground' && theme(styles, 'selected-tab-pane')
)}
>
<div className={theme(styles, 'api-playground')}>
<ServiceForm
onChange={this._handlePlaygroundChange}
restrictToFirstExample
service={service}
/>
</div>
</div>
{this._example.snippets.map((l, i) => (
<div
className={theme(
styles,
'tab-pane',
selected === l.label && theme(styles, 'selected-tab-pane')
)}
key={i}
>
<CodeBlock
className={theme(styles, 'code')}
language={l.language}
value={l.code}
/>
</div>
))}
</div>
<div className={theme(styles, 'footer')}>
<div className={theme(styles, 'footer__service')}>
{this._example.description && (
<div
className={theme(
styles,
'footer__service__example-description'
)}
>
Example - {this._example.description}
</div>
)}
<div className={theme(styles, 'footer__service__path')}>
<div
className={theme(
styles,
'footer__service__badge',
theme(
styles,
`footer__service__badge--${service.httpMethod.toUpperCase()}`
)
)}
>
{service.httpMethod.toUpperCase()}
</div>
/
<div className={theme(styles, 'footer__service__name')}>
{service.path.slice(1)}
</div>
</div>
</div>
<div className={theme(styles, 'footer__actions')}>
{selected !== 'Playground' && (
<div className={theme(styles, 'footer__action')}>
<Tooltip
placement='top'
title={
copiedTextToClipboard ? 'Copied!' : 'Copy to clipboard'
}
>
<Button
icon='copy'
type='primary'
className={theme(styles, 'copy')}
onClick={this._onClickCopy}
/>
</Tooltip>
</div>
)}
<div className={theme(styles, 'footer__action')}>
<Button
onClick={this._onClickRun}
type='secondary'
loading={running}
>
Run example
</Button>
</div>
</div>
</div>
{renderedOutput && (
<div
className={theme(
styles,
'output',
hitRateLimit && theme(styles, 'output--hit-rate-limit'),
codeBlockOutputFlush && theme(styles, 'output--flush')
)}
>
{renderedOutput}
</div>
)}
</div>
)
}
_onClickTab = (language) => {
this.setState({
selected: language.label
})
}
_onClickCopy = () => {
const { selected } = this.state
const snippet = this._example.snippets.find((l) => l.label === selected)
copyTextToClipboard(snippet.code)
this.setState({ copiedTextToClipboard: true })
this._clearCopyTimeout()
this._copyTimeout = setTimeout(this._onCopyTimeout, 3000)
}
_onCopyTimeout = () => {
this._clearCopyTimeout()
this.setState({ copiedTextToClipboard: false })
}
_clearCopyTimeout = () => {
if (this._copyTimeout) {
clearTimeout(this._copyTimeout)
this._copyTimeout = null
}
}
_onClickRun = async () => {
const { auth, service } = this.props
this.setState({
running: true,
output: null,
outputUrl: null,
outputContentType: null,
outputError: null,
downloaded: false,
hitRateLimit: null
})
const data = {
...this._example.input,
...this.state.values
}
const builtInExample = service.examples.find(
(e) =>
isDeepEqual(e.input, data) &&
e.outputContentType &&
(e.outputUrl || e.output)
)
let result
console.log({ data, builtInExample })
async function handleResult() {
if (!result.hitRateLimit && result.outputContentType) {
if (result.outputUrl) {
if (result.outputContentType.startsWith('image/')) {
// TODO: clean this up
} else {
window.open(result.outputUrl)
}
} else {
if (result.outputContentType.startsWith('application/octet-stream')) {
const overrides = await getContentTypeForBuffer(result.output)
if (overrides) {
// TODO: clean this up
result.origOutput = result.output
result = {
...result,
...overrides
}
}
}
if (
result.outputContentType.startsWith('text/') &&
!result.outputContentType.startsWith('text/html') &&
!result.outputContentType.startsWith('text/csv')
) {
// TODO: clean this up
} else if (result.outputContentType.startsWith('application/json')) {
// TODO: clean this up
} else if (result.outputContentType.startsWith('image/')) {
// TODO: clean this up
} else {
const blob = new window.Blob([result.origOutput || result.output], {
type: result.outputContentType
})
const ext = mime.extension(result.outputContentType)
const filename = ext ? `example.${ext}` : `example`
result.downloaded = true
download(blob, filename, result.outputContentType)
}
}
}
}
if (builtInExample) {
result = {
output: builtInExample.output,
outputUrl: builtInExample.outputUrl,
outputContentType: builtInExample.outputContentType
}
await new Promise((resolve) => {
setTimeout(resolve, 750)
})
await handleResult()
} else {
result = await requestService({
auth,
service,
data
})
await handleResult()
}
this.setState({
...result,
running: false
})
}
_handlePlaygroundChange = (values) => {
this.setState({ values })
}
}
// TODO: move this stuff into a separate library shared between here and the backend
async function getContentTypeForBuffer(buffer) {
try {
const ft = await fileType.fromBuffer(buffer)
if (ft) {
return {
outputContentType: ft.mime
}
}
const str = buffer.toString('utf8')
if (isHtml(str)) {
return {
outputContentType: 'text/html',
output: str
}
}
try {
const json = JSON.parse(str)
return {
outputContentType: 'application/json',
output: json
}
} catch (err) {
// purposefully ignore
}
// TODO: this CSV parser is way too lenient
const csv = detectCsv(str)
if (csv) {
return {
outputContentType: 'text/csv',
output: str
}
}
} catch (err) {
console.warn('getContentTypeForBuffer error', err)
}
}