From d4c3a83eb08e41473f78ed3defa4b9e3dbd1cdfd Mon Sep 17 00:00:00 2001 From: Jon Ursenbach Date: Tue, 24 Nov 2020 10:23:16 -0600 Subject: [PATCH] feat: request body raw/json mode editor (#1050) * chore(deps): upgrading oas to v6 * chore(deps): upgrading @readme/ui * chore(deps): upgrading oas to 6.1.0 * refactor: using oas mimetype tooling for contenttype matching * chore: rebuilding some lock files * feat: first pass at the json editor * chore(deps): upgrading @readme/syntax-highlighter * style(json tab): style the thing * chore(deps): upgrading @readme/syntax-highlighter * style(json): try it popover styles * refactor(json tab): better css var names * fix(json tab): fix false in classname * style(json tab): i was 1px off * fix: removing an unused component import * chore: removing some debug * test: fixing broken tests * test: fixing some react proptype messes to eliminate test warnings * style: fixing eslint issuese * refactor: updating some operation schema instances to use operation getters * fix: catching and tossing promise errors. fixes errors in tests * chore: removing unnecessary changes i made * test: adding some unit tests for the Doc component * fix: reworking the json codeeditor so invalid json can always be reset * test: more test coverage * chore(deps): upgrading oas to 6.1.0 * chore: reverting some unnecessary changes * fix: using the new getOperationId accessor * fix: using the new getResponseByStatusCode accessor * fix: typo that was breaking every test * feat: using a new oas accessor * test: slightly reducing jest coverage requirements * fix: rearchitecting how we're storing raw json to fix codeeditor issues * fix(try it): fix try it button turning red when not invalid, but disabled * fix(try it): only show popover on hover * feat: saving two dirty states for each form tab Co-authored-by: Tony Li --- example/src/Demo.jsx | 14 +- jest.config.js | 2 +- package-lock.json | 6 +- packages/api-explorer/__tests__/Doc.test.jsx | 258 ++++++++++++++++- .../api-explorer/__tests__/Params.test.jsx | 4 +- .../api-explorer/__tests__/PathUrl.test.jsx | 46 ++- .../__tests__/ResponseSchema.test.jsx | 3 +- .../__tests__/block-types/Code.test.jsx | 9 + .../lib/content-type-is-json.test.js | 23 -- packages/api-explorer/package.json | 4 +- packages/api-explorer/src/CodeSample.jsx | 8 +- packages/api-explorer/src/CopyCode.jsx | 2 +- packages/api-explorer/src/Doc.jsx | 262 ++++++++++++++++-- packages/api-explorer/src/Params.jsx | 217 ++++++++++----- packages/api-explorer/src/PathUrl.jsx | 33 ++- packages/api-explorer/src/ResponseBody.jsx | 12 +- packages/api-explorer/src/ResponseExample.jsx | 10 +- packages/api-explorer/src/ResponseSchema.jsx | 1 - packages/api-explorer/src/ResponseTabs.jsx | 13 +- .../src/block-types/CodeElement.jsx | 15 +- .../src/form-components/CodeEditor.jsx | 33 +++ packages/api-explorer/src/index.jsx | 3 + .../src/lib/content-type-is-json.js | 6 - .../api-explorer/src/lib/parse-response.js | 4 +- packages/api-explorer/style/main.scss | 157 ++++++++++- packages/markdown-magic/components/Code.jsx | 2 +- packages/oas-to-snippet/package-lock.json | 6 +- packages/oas-to-snippet/package.json | 2 +- 28 files changed, 960 insertions(+), 195 deletions(-) delete mode 100644 packages/api-explorer/__tests__/lib/content-type-is-json.test.js create mode 100644 packages/api-explorer/src/form-components/CodeEditor.jsx delete mode 100644 packages/api-explorer/src/lib/content-type-is-json.js diff --git a/example/src/Demo.jsx b/example/src/Demo.jsx index caa9e62dd..23b817229 100644 --- a/example/src/Demo.jsx +++ b/example/src/Demo.jsx @@ -16,6 +16,7 @@ class Demo extends React.Component { super(props); this.state = { brokenExplorerState: false, + enableJsonEditor: true, enableTutorials: false, maskErrorMessages: false, useNewMarkdownEngine: true, @@ -36,6 +37,10 @@ class Demo extends React.Component { description: null, stateProp: 'useNewMarkdownEngine', }, + 'Enable JSON editor (beta)': { + description: 'Enable our request body JSON editor.', + stateProp: 'enableJsonEditor', + }, 'Enable tutorials (beta)': { description: 'Enable our tutorials beta.', stateProp: 'enableTutorials', @@ -72,7 +77,13 @@ class Demo extends React.Component { render() { const { fetchSwagger, status, docs, oas, oasUrl, oauth } = this.props; - const { brokenExplorerState, enableTutorials, maskErrorMessages, useNewMarkdownEngine } = this.state; + const { + brokenExplorerState, + enableJsonEditor, + enableTutorials, + maskErrorMessages, + useNewMarkdownEngine, + } = this.state; const additionalProps = { oasFiles: { @@ -146,6 +157,7 @@ class Demo extends React.Component { docs={docs} // We only really set this to `true` for testing sites for errors using puppeteer dontLazyLoad={false} + enableRequestBodyJsonEditor={enableJsonEditor} flags={{ correctnewlines: false }} glossaryTerms={[{ term: 'apiKey', definition: 'This is a definition' }]} Logs={Logs} diff --git a/jest.config.js b/jest.config.js index e3510d09d..b3c480cb5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,7 +10,7 @@ module.exports = { coverageThreshold: { global: { branches: 88, - functions: 90, + functions: 88, lines: 90, statements: 90, }, diff --git a/package-lock.json b/package-lock.json index 09a232af2..628a92f76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8251,9 +8251,9 @@ } }, "@readme/syntax-highlighter": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@readme/syntax-highlighter/-/syntax-highlighter-10.2.0.tgz", - "integrity": "sha512-tL+0vyJIFrsCsLGfMPoslagxn9Jh6EtElgEM9qUrzTLfWqCToH7C1stTyqM3WDdQTRzun3lWsFRERuaFy3yxSg==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@readme/syntax-highlighter/-/syntax-highlighter-10.3.1.tgz", + "integrity": "sha512-E8W6njPCcecZaRjzK61XEA+VmpOIh8inanyxkLKiwOdJgi17Idq2qYHgxPiYOwxq8ceen8F4dA8BKGXVjG1cAQ==", "requires": { "@readme/variable": "^7.2.1", "codemirror": "^5.48.2", diff --git a/packages/api-explorer/__tests__/Doc.test.jsx b/packages/api-explorer/__tests__/Doc.test.jsx index b74f95d2c..50b36d9be 100644 --- a/packages/api-explorer/__tests__/Doc.test.jsx +++ b/packages/api-explorer/__tests__/Doc.test.jsx @@ -5,10 +5,12 @@ global.Request = Request; const React = require('react'); const { shallow, mount } = require('enzyme'); +const { waitFor } = require('@testing-library/dom'); const Doc = require('../src/Doc'); const ErrorBoundary = require('../src/ErrorBoundary'); const petstore = require('@readme/oas-examples/3.0/json/petstore.json'); +const uspto = require('@readme/oas-examples/3.0/json/uspto.json'); const petstoreWithAuth = require('./__fixtures__/petstore/oas.json'); const multipleSecurities = require('./__fixtures__/multiple-securities/oas.json'); @@ -23,6 +25,7 @@ const props = { type: 'endpoint', }, language: 'node', + lazy: false, oas: petstore, oauth: false, onAuthChange: () => {}, @@ -32,6 +35,22 @@ const props = { tryItMetrics: () => {}, }; +const petExample = { + category: { + id: 0, + name: 'string', + }, + name: 'doggie', + photoUrls: ['string'], + status: 'available', + tags: [ + { + id: 0, + name: 'string', + }, + ], +}; + function assertDocElements(component, doc) { expect(component.find(`#page-${doc.slug}`)).toHaveLength(1); expect(component.find('a.anchor-page-title')).toHaveLength(1); @@ -153,14 +172,14 @@ describe('state.dirty', () => { it('should default to false', () => { const doc = shallow(); - expect(doc.state('dirty')).toBe(false); + expect(doc.state('dirty')).toStrictEqual({ form: false, json: false }); }); it('should switch to true on form change', () => { const doc = shallow(); doc.instance().onChange({ a: 1 }); - expect(doc.state('dirty')).toBe(true); + expect(doc.state('dirty')).toStrictEqual({ form: true, json: false }); }); }); @@ -318,14 +337,14 @@ describe('suggest edits', () => { }); }); -describe('Response Schema', () => { - it('should render Response Schema if endpoint does have a response', () => { - const doc = mount(); +describe('ResponseSchema', () => { + it('should render ResponseSchema if endpoint does have a response', () => { + const doc = shallow(); doc.setState({ showEndpoint: true }); expect(doc.find('ResponseSchema')).toHaveLength(1); }); - it('should not render Response Schema if endpoint does not have a response', () => { + it('should not render ResponseSchema if endpoint does not have a response', () => { const doc = shallow( { oas={multipleSecurities} /> ); + expect(doc.find('ResponseSchema')).toHaveLength(0); }); }); describe('RenderLogs', () => { it('should return a log component', () => { - const doc = mount(); - doc.setProps({ Logs: () => {} }); + const doc = shallow( {}} />); const res = doc.instance().renderLogs(); expect(typeof res).toBe('object'); }); @@ -354,7 +373,7 @@ describe('RenderLogs', () => { describe('themes', () => { it('should output code samples and responses in the right column', () => { - const doc = mount(); + const doc = shallow(); doc.setState({ showEndpoint: true }); expect(doc.find('.hub-reference-right').find('CodeSample')).toHaveLength(1); @@ -399,11 +418,9 @@ describe('error handling', () => { it('should output with a masked error message if the endpoint fails to load', () => { const doc = mount(); - doc.setState({ showEndpoint: true }); + expect(doc.find(ErrorBoundary)).toHaveLength(1); const html = doc.html(); - - expect(doc.find(ErrorBoundary)).toHaveLength(1); expect(html).not.toMatch('support@readme.io'); expect(html).toMatch("endpoint's documentation"); }); @@ -440,3 +457,220 @@ describe('error handling', () => { expect(html).toMatch(/ERR-([0-9A-Z]{6})/); }); }); + +describe('#enableRequestBodyJsonEditor', () => { + it('should not show the editor on an operation without a request body', () => { + const doc = shallow(); + + expect(doc.find('Tabs')).toHaveLength(0); + }); + + it('should not show the editor on an operation with a non-json request body', () => { + const doc = mount( + + ); + + expect(doc.find('Tabs')).toHaveLength(0); + }); + + it('should show the editor on an operation with a json-compatible request body', () => { + const doc = mount( + + ); + + expect(doc.find('Tabs')).toHaveLength(1); + }); + + describe('#formDataJson', () => { + it('should fill formDataJson with an example request body', () => { + const doc = mount( + + ); + + return waitFor(() => { + expect(doc.state('formDataJson')).toStrictEqual(petExample); + expect(doc.state('formDataJsonOriginal')).toStrictEqual(doc.state('formDataJson')); + expect(doc.state('formDataJsonRaw')).toMatch('"status": "available"'); + }); + }); + }); +}); + +describe('#resetForm()', () => { + it('should reset formDataJson', () => { + const doc = mount( + + ); + + return waitFor(() => { + expect(doc.state('formDataJson')).toStrictEqual(petExample); + + doc.setState({ formDataJson: { name: 'buster' } }); + + doc.instance().resetForm(); + + expect(doc.state('formDataJson')).toStrictEqual(petExample); + expect(doc.state('formDataJsonRaw')).toMatch('"status": "available"'); + expect(doc.state('validationErrors')).toStrictEqual({ form: false, json: false }); + expect(doc.state('dirty')).toStrictEqual({ form: true, json: false }); + }); + }); +}); + +describe('#onJsonChange()', () => { + it('should update formDataJson when given valid json', () => { + const doc = shallow( + + ); + + doc.instance().onJsonChange(JSON.stringify({ name: 'buster' })); + + expect(doc.state('formDataJson')).toStrictEqual({ name: 'buster' }); + expect(doc.state('formDataJsonRaw')).toStrictEqual(JSON.stringify({ name: 'buster' })); + expect(doc.state('validationErrors')).toStrictEqual({ form: false, json: false }); + expect(doc.state('dirty')).toStrictEqual({ form: false, json: true }); + }); + + it('should set validation errors when given invalid JSON', () => { + const doc = shallow( + + ); + + doc.instance().onJsonChange(JSON.stringify({ name: 'buster' })); + doc.instance().onJsonChange('{ invalid json }'); + + expect(doc.state('formDataJson')).toStrictEqual({ name: 'buster' }); + expect(doc.state('formDataJsonRaw')).toStrictEqual('{ invalid json }'); + expect(doc.state('validationErrors')).toStrictEqual({ + form: false, + json: expect.any(String), + }); + + expect(doc.state('dirty')).toStrictEqual({ form: false, json: true }); + }); +}); + +describe('#onModeChange()', () => { + it('should change the editing mode', () => { + const doc = shallow(); + + expect(doc.state('editingMode')).toBe('form'); + + doc.instance().onModeChange('JSON'); + expect(doc.state('editingMode')).toBe('json'); + }); +}); + +describe('#isDirty()', () => { + it('should return a dirty state based on the current editing mode', () => { + const doc = shallow(); + + doc.setState({ dirty: { form: true, json: false } }); + + expect(doc.instance().isDirty()).toBe(true); + + doc.setState({ editingMode: 'json' }); + + expect(doc.instance().isDirty()).toBe(false); + }); +}); + +describe('#getValidationErrors()', () => { + it('should return validation errors based on the current editing mode', () => { + const doc = shallow(); + + doc.setState({ validationErrors: { form: false, json: 'invalid json' } }); + + expect(doc.instance().getValidationErrors()).toBe(false); + + doc.setState({ editingMode: 'json' }); + + expect(doc.instance().getValidationErrors()).toBe('invalid json'); + }); +}); + +describe('#getFormDataForCurrentMode()', () => { + it('should pull back the appropriate form data for the current editing mode', () => { + const doc = shallow(); + + doc.setState({ + formData: { + headers: { + 'x-breed': 'pug', + }, + body: { + name: 'booster', + }, + }, + formDataJson: { + name: 'buster', + }, + }); + + expect(doc.instance().getFormDataForCurrentMode()).toStrictEqual({ + headers: { 'x-breed': 'pug' }, + body: { name: 'booster' }, + }); + + doc.setState({ editingMode: 'json' }); + + expect(doc.instance().getFormDataForCurrentMode()).toStrictEqual({ + headers: { 'x-breed': 'pug' }, + body: { name: 'buster' }, + }); + }); +}); diff --git a/packages/api-explorer/__tests__/Params.test.jsx b/packages/api-explorer/__tests__/Params.test.jsx index a10146646..81e03cdd4 100644 --- a/packages/api-explorer/__tests__/Params.test.jsx +++ b/packages/api-explorer/__tests__/Params.test.jsx @@ -20,11 +20,13 @@ const booleanOas = new Oas(boolean); const stringOas = new Oas(string); const props = { - formData: {}, oas, onChange: () => {}, + onJsonChange: () => {}, + onModeChange: () => {}, onSubmit: () => {}, operation, + resetForm: () => {}, }; const Params = createParams(oas, operation); diff --git a/packages/api-explorer/__tests__/PathUrl.test.jsx b/packages/api-explorer/__tests__/PathUrl.test.jsx index 40e94d530..6286113a5 100644 --- a/packages/api-explorer/__tests__/PathUrl.test.jsx +++ b/packages/api-explorer/__tests__/PathUrl.test.jsx @@ -21,6 +21,7 @@ const props = { onChange: () => {}, onSubmit: () => {}, operation: oas.operation('/pet/{petId}', 'get'), + resetForm: () => {}, toggleAuth: () => {}, }; @@ -60,19 +61,42 @@ describe('dirty prop', () => { }); }); -test('button click should call onSubmit', () => { - let called = false; - function onSubmit() { - called = true; - } +describe('#resetForm()', () => { + it('should fire when clicked', () => { + const resetForm = jest.fn(); - shallow( - - ) - .find('button[type="submit"]') - .simulate('click'); + shallow() + .find('.api-try-it-out-popover div[role="button"]') + .simulate('click'); - expect(called).toBe(true); + expect(resetForm).toHaveBeenCalled(); + }); +}); + +describe('#validationErrors', () => { + it('should not show validation errors if there are none', () => { + expect(shallow().find('.api-try-it-out-popover')).toHaveLength(0); + }); + + it('should show validation errors', () => { + expect( + shallow().find('.api-try-it-out-popover') + ).toHaveLength(1); + }); +}); + +describe('#onSubmit()', () => { + it('button click should call onSubmit', () => { + const onSubmit = jest.fn(); + + shallow( + + ) + .find('button[type="submit"]') + .simulate('click'); + + expect(onSubmit).toHaveBeenCalled(); + }); }); describe('splitPath()', () => { diff --git a/packages/api-explorer/__tests__/ResponseSchema.test.jsx b/packages/api-explorer/__tests__/ResponseSchema.test.jsx index 71ad21d0c..a9a265a0e 100644 --- a/packages/api-explorer/__tests__/ResponseSchema.test.jsx +++ b/packages/api-explorer/__tests__/ResponseSchema.test.jsx @@ -41,7 +41,8 @@ test.each([[true], [false]])( } else { text = responseSchema.find('div.desc').first().text(); } - expect(text).toBe(props.operation.schema.responses['200'].description); + + expect(text).toBe(props.operation.getResponseByStatusCode(200).description); } ); diff --git a/packages/api-explorer/__tests__/block-types/Code.test.jsx b/packages/api-explorer/__tests__/block-types/Code.test.jsx index 18f05769c..b122f5591 100644 --- a/packages/api-explorer/__tests__/block-types/Code.test.jsx +++ b/packages/api-explorer/__tests__/block-types/Code.test.jsx @@ -78,9 +78,18 @@ test('Code will render status code within em tag', () => { }); test('If codes array is not passed as an array expect empty array', () => { + // We're mocking out `console.error` because we're intentionally going against what's set for the `block.data.codes` + // proptype and it's annoying having React warn us about something we know is going to be a warning. + /* eslint-disable no-console */ + const originalError = console.error; + console.error = jest.fn(); + const codeInput = mount(); + console.error = originalError; + expect(codeInput.find('span').text()).toBe(''); + /* eslint-enable no-console */ }); test('Code will render language if name or status is not provided within a tag if codes has a status', () => { diff --git a/packages/api-explorer/__tests__/lib/content-type-is-json.test.js b/packages/api-explorer/__tests__/lib/content-type-is-json.test.js deleted file mode 100644 index adcac7823..000000000 --- a/packages/api-explorer/__tests__/lib/content-type-is-json.test.js +++ /dev/null @@ -1,23 +0,0 @@ -const contentTypeIsJson = require('../../src/lib/content-type-is-json'); - -describe('contentTypeIsJson', () => { - it('should return true if application/json', () => { - const contentType = 'application/json'; - expect(contentTypeIsJson(contentType)).toBe(true); - }); - - it('should return true if application/vnd.api+json', () => { - const contentType = 'application/vnd.api+json'; - expect(contentTypeIsJson(contentType)).toBe(true); - }); - - it('should return true if application/vnd.github.v3.star+json', () => { - const contentType = 'application/vnd.github.v3.star+json'; - expect(contentTypeIsJson(contentType)).toBe(true); - }); - - it('should return false otherwise', () => { - const contentType = 'text/html'; - expect(contentTypeIsJson(contentType)).toBe(false); - }); -}); diff --git a/packages/api-explorer/package.json b/packages/api-explorer/package.json index a532654ad..99c3c5fa2 100644 --- a/packages/api-explorer/package.json +++ b/packages/api-explorer/package.json @@ -25,7 +25,7 @@ "@readme/oas-form": "^9.3.0", "@readme/oas-to-har": "^9.2.2", "@readme/oas-to-snippet": "^9.3.0", - "@readme/syntax-highlighter": "10.2.*", + "@readme/syntax-highlighter": "^10.3.1", "@readme/variable": "^9.3.0", "classnames": "^2.2.5", "fetch-har": "^4.0.2", @@ -49,7 +49,7 @@ "@readme/eslint-config": "^3.2.0", "@readme/markdown": "^6.21.0", "@readme/oas-examples": "^3.6.0", - "@readme/ui": "^1.11.0", + "@readme/ui": "^1.14.0", "@testing-library/dom": "^7.26.3", "css-loader": "^4.3.0", "eslint": "^7.0.0", diff --git a/packages/api-explorer/src/CodeSample.jsx b/packages/api-explorer/src/CodeSample.jsx index f533332e6..dba77b6f0 100644 --- a/packages/api-explorer/src/CodeSample.jsx +++ b/packages/api-explorer/src/CodeSample.jsx @@ -68,7 +68,7 @@ class CodeSample extends React.Component {
-                  {syntaxHighlighter(example.code, example.language, { dark: true })}
+                  {syntaxHighlighter(example.code, example.language, { customTheme: 'tomorrow-night' })}
                 
); @@ -98,7 +98,7 @@ class CodeSample extends React.Component { let snippet; const { code, highlightMode } = generateCodeSnippet(oas, operation, formData, auth, language, oasUrl); if (code && highlightMode) { - snippet = syntaxHighlighter(code, highlightMode, { dark: true }); + snippet = syntaxHighlighter(code, highlightMode, { customTheme: 'tomorrow-night' }); } return ( @@ -144,8 +144,8 @@ CodeSample.propTypes = { auth: PropTypes.shape({}).isRequired, examples: PropTypes.arrayOf( PropTypes.shape({ - code: PropTypes.string.isRequired, - language: PropTypes.string.isRequired, + code: PropTypes.string, + language: PropTypes.string, }) ), formData: PropTypes.shape({}).isRequired, diff --git a/packages/api-explorer/src/CopyCode.jsx b/packages/api-explorer/src/CopyCode.jsx index aa905ec74..d67270842 100644 --- a/packages/api-explorer/src/CopyCode.jsx +++ b/packages/api-explorer/src/CopyCode.jsx @@ -40,7 +40,7 @@ class CopyCode extends React.Component { } CopyCode.propTypes = { - code: PropTypes.string.isRequired, + code: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, }; module.exports = CopyCode; diff --git a/packages/api-explorer/src/Doc.jsx b/packages/api-explorer/src/Doc.jsx index 1ff71f64e..3167c1d88 100644 --- a/packages/api-explorer/src/Doc.jsx +++ b/packages/api-explorer/src/Doc.jsx @@ -8,6 +8,7 @@ const { Waypoint } = require('react-waypoint'); const oasToHar = require('@readme/oas-to-har'); const Oas = require('oas/tooling'); const { getPath } = require('oas/tooling/utils'); +const { matchesMimeType } = require('oas/tooling/utils'); const { TutorialTile, TutorialModal } = require('@readme/ui/.bundles/es/ui/compositions'); require('@readme/ui/.bundles/umd/main.css'); @@ -25,44 +26,194 @@ const { Operation } = Oas; const parseResponse = require('./lib/parse-response'); const Content = require('./block-types/Content'); -const pkg = require('../package.json'); +const { version: packageVersion } = require('../package.json'); + +const stringifyPretty = data => JSON.stringify(data, undefined, 2); class Doc extends React.Component { constructor(props) { super(props); this.state = { + dirty: { + form: false, + json: false, + }, + + editingMode: 'form', formData: {}, - dirty: false, + + // For raw mode we should default the body to an empty JSON object. If this operation has a + // request body example that we can use, it'll get filled in when the `componentDidMount` + // event kicks. + formDataJson: {}, + + // This'll hold a copy of the original raw JSON block incase the user wants to reset their + // changes. + formDataJsonOriginal: {}, + + // This holds a copy of whatever data the user is currently inputting into the JSON request + // code editor. On page load it'll hold the contents of `formDataJson` until the user makes + // changes. + formDataJsonRaw: '{}', + loading: false, - showAuthBox: false, needsAuth: false, result: null, - showEndpoint: false, selectedTutorial: null, + showAuthBox: false, + showEndpoint: false, showTutorialModal: false, + + validationErrors: { + form: false, + json: false, + }, }; + this.enableRequestBodyJsonEditor = false; + this.closeTutorialModal = this.closeTutorialModal.bind(this); this.hideResults = this.hideResults.bind(this); - this.onChange = this.onChange.bind(this); + this.oas = new Oas(this.props.oas, this.props.user); + + this.onChange = this.onChange.bind(this); + this.onJsonChange = this.onJsonChange.bind(this); + this.onModeChange = this.onModeChange.bind(this); this.onSubmit = this.onSubmit.bind(this); + + this.openTutorial = this.openTutorial.bind(this); this.operation = this.getOperation(); this.Params = createParams(this.oas, this.operation); + + this.resetForm = this.resetForm.bind(this); + this.toggleAuth = this.toggleAuth.bind(this); this.waypointEntered = this.waypointEntered.bind(this); - this.openTutorial = this.openTutorial.bind(this); - this.closeTutorialModal = this.closeTutorialModal.bind(this); + } + + componentDidMount() { + if (!this.shouldEnableRequestBodyJsonEditor()) { + return; + } + + this.operation + .getRequestBodyExamples() + .then(examples => { + if (!Object.keys(examples).length) { + return; + } + + const jsonExamples = examples.filter( + ex => matchesMimeType.json(ex.mediaType) || matchesMimeType.wildcard(ex.mediaType) + ); + + if (jsonExamples.length) { + const example = jsonExamples[0]; + let code = false; + + if (example.code) { + code = example.code; + } else if (example.multipleExamples) { + code = example.multipleExamples[0].code; + } + + try { + // Examples are stringified when we get them from `oas` because they need to be stringified for + // `@readme/syntax-highlighter` but because we need to pass a usable non-stringified object/array/primitive + // to our `CodeSample` component and `@readme/oas-to-har` we're parsing it out here. Cool? Cool. + code = JSON.parse(code); + } catch (e) { + code = {}; + } + + code = code || {}; + + this.setState({ + formDataJson: code, + formDataJsonOriginal: code, + formDataJsonRaw: stringifyPretty(code), + }); + } + }) + .catch(() => { + // If we fail to generate examples for whatever reason fail silently. + }); + } + + resetForm() { + this.setState(previousState => { + return { + dirty: { + ...previousState.dirty, + json: false, + }, + formDataJson: previousState.formDataJsonOriginal, + formDataJsonRaw: stringifyPretty(previousState.formDataJsonOriginal), + validationErrors: { + ...previousState.validationErrors, + json: false, + }, + }; + }); } onChange(formData) { this.setState(previousState => { return { + dirty: { + ...previousState.dirty, + form: true, + }, formData: { ...previousState.formData, ...formData }, - dirty: true, + validationErrors: { + ...previousState.validationErrors, + form: false, + }, }; }); } + onJsonChange(rawData) { + this.setState(previousState => { + let data; + try { + data = JSON.parse(rawData); + + return { + dirty: { + ...previousState.dirty, + json: true, + }, + formDataJson: data, + formDataJsonRaw: rawData, + validationErrors: { + ...previousState.validationErrors, + json: false, + }, + }; + } catch (err) { + return { + dirty: { + ...previousState.dirty, + json: true, + }, + formDataJson: previousState.formDataJson, + formDataJsonRaw: rawData, + validationErrors: { + ...previousState.validationErrors, + json: err.message, + }, + }; + } + }); + } + + onModeChange(mode) { + this.setState({ + editingMode: mode.toLowerCase(), + }); + } + onSubmit() { if (!isAuthReady(this.operation, this.props.auth)) { this.setState({ showAuthBox: true }); @@ -75,7 +226,7 @@ class Doc extends React.Component { this.setState({ loading: true, showAuthBox: false, needsAuth: false }); - const har = oasToHar(this.oas, this.operation, this.state.formData, this.props.auth, { + const har = oasToHar(this.oas, this.operation, this.getFormDataForCurrentMode(), this.props.auth, { proxyUrl: true, }); @@ -86,7 +237,7 @@ class Doc extends React.Component { // instead. // // https://stackoverflow.com/questions/42815087/sending-a-custom-user-agent-string-along-with-my-headers-fetch - request.headers.append('x-readme-api-explorer', pkg.version); + request.headers.append('x-readme-api-explorer', packageVersion); return fetch(request).then(async res => { this.props.tryItMetrics(har, res); @@ -98,6 +249,29 @@ class Doc extends React.Component { }); } + isDirty() { + return this.state.editingMode === 'form' ? this.state.dirty.form : this.state.dirty.json; + } + + getValidationErrors() { + const { editingMode, validationErrors } = this.state; + + return editingMode === 'form' ? validationErrors.form : validationErrors.json; + } + + getFormDataForCurrentMode() { + const { editingMode, formData, formDataJson } = this.state; + + if (editingMode === 'form') { + return formData; + } + + return { + ...formData, + body: formDataJson, + }; + } + getOperation() { if (this.operation) return this.operation; @@ -133,6 +307,23 @@ class Doc extends React.Component { this.setState(() => ({ showTutorialModal: false, selectedTutorial: null })); } + shouldEnableRequestBodyJsonEditor() { + // Instead of just relying on if the prop is set, or setting this in the component constructor, we're checking if + // it's changed against the current stored value because in the demo server you can toggle raw mode on and off and + // we should only do these supplemental checks if we absolutely need to. + if (this.enableRequestBodyJsonEditor !== this.props.enableRequestBodyJsonEditor) { + // Request body raw mode should only be enabled if the operation has a request body to fill out and its delivered + // with a JSON-compatible media type. + if (this.operation.hasRequestBody()) { + if (this.operation.isJson()) { + this.enableRequestBodyJsonEditor = this.props.enableRequestBodyJsonEditor; + } + } + } + + return this.enableRequestBodyJsonEditor; + } + mainTheme(doc) { const { useNewMarkdownEngine } = this.props; @@ -226,7 +417,7 @@ class Doc extends React.Component { + ) + ); + } + renderLogs() { if (!this.props.Logs) return null; const { Logs } = this.props; @@ -310,13 +519,21 @@ class Doc extends React.Component { } renderParams() { + const { formData, formDataJsonRaw, validationErrors } = this.state; + return ( ); } @@ -326,7 +543,7 @@ class Doc extends React.Component { (this.authInput = el)} // eslint-disable-line no-return-assign - dirty={this.state.dirty} + dirty={this.isDirty()} group={this.props.group} groups={this.props.groups} loading={this.state.loading} @@ -337,8 +554,10 @@ class Doc extends React.Component { onChange={this.props.onAuthChange} onSubmit={this.onSubmit} operation={this.getOperation()} + resetForm={this.resetForm} showAuthBox={this.state.showAuthBox} toggleAuth={this.toggleAuth} + validationErrors={this.getValidationErrors()} /> ); } @@ -352,7 +571,7 @@ class Doc extends React.Component { if (lazy) { return ( - {this.state.showEndpoint && this.renderEndpoint()} + {this.state.showEndpoint ? this.renderEndpoint() :
} ); } @@ -399,17 +618,8 @@ class Doc extends React.Component { // cos we can just pass it around? } - {this.state.selectedTutorial && ( - - )} + + {this.renderTutorial()}
); } @@ -448,6 +658,7 @@ Doc.propTypes = { tutorials: PropTypes.arrayOf(PropTypes.shape({})), type: PropTypes.string.isRequired, }).isRequired, + enableRequestBodyJsonEditor: PropTypes.bool, flags: PropTypes.shape({ correctnewlines: PropTypes.bool, }), @@ -482,6 +693,7 @@ Doc.defaultProps = { splitReferenceDocs: false, }, baseUrl: '/', + enableRequestBodyJsonEditor: false, flags: { correctnewlines: false, }, diff --git a/packages/api-explorer/src/Params.jsx b/packages/api-explorer/src/Params.jsx index 80915b610..be2d45a7c 100644 --- a/packages/api-explorer/src/Params.jsx +++ b/packages/api-explorer/src/Params.jsx @@ -2,13 +2,14 @@ const React = require('react'); const PropTypes = require('prop-types'); const Form = require('@readme/oas-form').default; const extensions = require('@readme/oas-extensions'); +const Oas = require('oas/tooling'); const { PasswordWidget, TextWidget, UpDownWidget } = require('@readme/oas-form/src/components/widgets').default; - -const Oas = require('oas/tooling'); +const { Button, Tabs } = require('@readme/ui/.bundles/es/ui/components'); const createArrayField = require('./form-components/ArrayField'); const createBaseInput = require('./form-components/BaseInput'); +const createCodeEditor = require('./form-components/CodeEditor'); const createFileWidget = require('./form-components/FileWidget'); const createSchemaField = require('./form-components/SchemaField'); const createSelectWidget = require('./form-components/SelectWidget'); @@ -27,9 +28,15 @@ class Params extends React.Component { this.jsonSchema = operation.getParametersAsJsonSchema(); this.operationId = operation.getOperationId(); + + this.onModeChange = this.onModeChange.bind(this); } - render() { + onModeChange(mode) { + return this.props.onModeChange(mode); + } + + getForm(schema) { const { ArrayField, BaseInput, @@ -45,76 +52,122 @@ class Params extends React.Component { } = this.props; return ( -
+
{ + return onChange({ [schema.type]: form.formData }); + }} + onSubmit={onSubmit} + schema={schema.schema} + widgets={{ + // 🚧 If new supported formats are added here, they must also be added to `SchemaField.getCustomType`. + BaseInput, + binary: FileWidget, + blob: TextareaWidget, + byte: TextWidget, + + // Due to the varying ways that `date` and `date-time` is utilized in API definitions for representing + // dates the lack of wide browser support, and that it's not RFC 3339 compliant we don't support the + // `date-time-local` input for `date-time` formats, instead treating them as general strings. + // + // @link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Browser_compatibility + // @link https://tools.ietf.org/html/rfc3339 + date: TextWidget, + dateTime: TextWidget, + 'date-time': TextWidget, + + double: UpDownWidget, + duration: TextWidget, + float: UpDownWidget, + html: TextareaWidget, + int8: UpDownWidget, + int16: UpDownWidget, + int32: UpDownWidget, + int64: UpDownWidget, + integer: UpDownWidget, + json: TextareaWidget, + password: PasswordWidget, + SelectWidget, + string: TextWidget, + timestamp: TextWidget, + uint8: UpDownWidget, + uint16: UpDownWidget, + uint32: UpDownWidget, + uint64: UpDownWidget, + uri: URLWidget, + url: URLWidget, + uuid: TextWidget, + }} + > +
+
+ + + + ) : ( + +
+

{schema.label}

+
+
+ + {this.getForm(schema)} + + )} + + ); })}
); @@ -124,21 +177,38 @@ class Params extends React.Component { Params.propTypes = { ArrayField: PropTypes.func.isRequired, BaseInput: PropTypes.func.isRequired, + CodeEditor: PropTypes.func.isRequired, + enableJsonEditor: PropTypes.bool, FileWidget: PropTypes.func.isRequired, - formData: PropTypes.shape({}).isRequired, + formData: PropTypes.shape({}), + formDataJsonRaw: PropTypes.string, oas: PropTypes.instanceOf(Oas).isRequired, onChange: PropTypes.func.isRequired, + onJsonChange: PropTypes.func.isRequired, + onModeChange: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired, operation: PropTypes.instanceOf(Operation).isRequired, + resetForm: PropTypes.func.isRequired, SchemaField: PropTypes.func.isRequired, SelectWidget: PropTypes.func.isRequired, TextareaWidget: PropTypes.func.isRequired, URLWidget: PropTypes.func.isRequired, useNewMarkdownEngine: PropTypes.bool, + validationErrors: PropTypes.shape({ + form: PropTypes.bool, + json: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), + }).isRequired, }; Params.defaultProps = { + enableJsonEditor: false, + formData: {}, + formDataJsonRaw: '{}', useNewMarkdownEngine: false, + validationErrors: { + form: false, + json: false, + }, }; function createParams(oas, operation) { @@ -162,6 +232,8 @@ function createParams(oas, operation) { const TextareaWidget = createTextareaWidget(explorerEnabled); const URLWidget = createURLWidget(explorerEnabled); + const CodeEditor = createCodeEditor(); + // eslint-disable-next-line react/display-name return props => { return ( @@ -169,6 +241,7 @@ function createParams(oas, operation) { {...props} ArrayField={ArrayField} BaseInput={BaseInput} + CodeEditor={CodeEditor} FileWidget={FileWidget} SchemaField={SchemaField} SelectWidget={SelectWidget} diff --git a/packages/api-explorer/src/PathUrl.jsx b/packages/api-explorer/src/PathUrl.jsx index 1b1086be4..f4ba8442d 100644 --- a/packages/api-explorer/src/PathUrl.jsx +++ b/packages/api-explorer/src/PathUrl.jsx @@ -37,8 +37,10 @@ function PathUrl({ onChange, onSubmit, operation, + resetForm, showAuthBox, toggleAuth, + validationErrors, }) { const explorerEnabled = extensions.getExtension(extensions.EXPLORER_ENABLED, oas, operation); @@ -64,20 +66,40 @@ function PathUrl({ /> )} @@ -117,8 +139,11 @@ PathUrl.propTypes = { onChange: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired, operation: PropTypes.instanceOf(Operation).isRequired, + resetForm: PropTypes.func.isRequired, showAuthBox: PropTypes.bool, + showValidationErrors: PropTypes.bool, toggleAuth: PropTypes.func.isRequired, + validationErrors: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), }; PathUrl.defaultProps = { @@ -126,6 +151,8 @@ PathUrl.defaultProps = { authInputRef: () => {}, needsAuth: false, showAuthBox: false, + showValidationErrors: false, + validationErrors: false, }; module.exports = PathUrl; diff --git a/packages/api-explorer/src/ResponseBody.jsx b/packages/api-explorer/src/ResponseBody.jsx index 437b27805..efdc3073a 100644 --- a/packages/api-explorer/src/ResponseBody.jsx +++ b/packages/api-explorer/src/ResponseBody.jsx @@ -2,11 +2,15 @@ const React = require('react'); const PropTypes = require('prop-types'); const syntaxHighlighter = require('@readme/syntax-highlighter/dist/index.js').default; const ReactJson = require('react-json-view').default; -const contentTypeIsJson = require('./lib/content-type-is-json'); const oauthHref = require('./lib/oauth-href'); +const { matchesMimeType } = require('oas/tooling/utils'); function Authorized({ result }) { - const isJson = result.type && contentTypeIsJson(result.type) && typeof result.responseBody === 'object'; + const isJson = + result.type && + (matchesMimeType.json(result.type) || matchesMimeType.wildcard(result.type)) && + typeof result.responseBody === 'object'; + return (
{result.isBinary &&
A binary file was returned
} @@ -30,7 +34,7 @@ function Authorized({ result }) { {!result.isBinary && !isJson && (
           
- {syntaxHighlighter(result.responseBody, result.type)} + {syntaxHighlighter(result.responseBody, result.type, { customTheme: 'tomorrow-night' })}
)} @@ -41,7 +45,7 @@ function Authorized({ result }) { Authorized.propTypes = { result: PropTypes.shape({ isBinary: PropTypes.bool, - responseBody: PropTypes.oneOf([PropTypes.object, PropTypes.string]).isRequired, + responseBody: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, type: PropTypes.string, }).isRequired, }; diff --git a/packages/api-explorer/src/ResponseExample.jsx b/packages/api-explorer/src/ResponseExample.jsx index afc5a1a74..4792b2f84 100644 --- a/packages/api-explorer/src/ResponseExample.jsx +++ b/packages/api-explorer/src/ResponseExample.jsx @@ -4,8 +4,8 @@ const ReactJson = require('react-json-view').default; const syntaxHighlighter = require('@readme/syntax-highlighter/dist/index.js').default; const extensions = require('@readme/oas-extensions'); const Oas = require('oas/tooling'); +const { matchesMimeType } = require('oas/tooling/utils'); -const contentTypeIsJson = require('./lib/content-type-is-json'); const upgradeLegacyResponses = require('./lib/upgrade-legacy-responses'); const ExampleTabs = require('./ExampleTabs'); @@ -177,7 +177,7 @@ class ResponseExample extends React.Component {
{syntaxHighlighter(example.code, language, { - dark: true, + customTheme: 'tomorrow-night', })}
@@ -211,7 +211,7 @@ class ResponseExample extends React.Component { return (
{syntaxHighlighter(hx.code, hx.language, { - dark: true, + customTheme: 'tomorrow-night', })}
); @@ -242,7 +242,9 @@ class ResponseExample extends React.Component { example = mediaTypes[0]; } - const isJson = example.language && contentTypeIsJson(example.language); + const isJson = + example.language && + (matchesMimeType.json(example.language) || matchesMimeType.wildcard(example.language)); return (
diff --git a/packages/api-explorer/src/ResponseSchema.jsx b/packages/api-explorer/src/ResponseSchema.jsx index de87d7de1..8204aaa1a 100644 --- a/packages/api-explorer/src/ResponseSchema.jsx +++ b/packages/api-explorer/src/ResponseSchema.jsx @@ -43,7 +43,6 @@ class ResponseSchema extends React.Component { getContent(operation, oas) { const status = this.state.selectedStatus; const response = operation.getResponseByStatusCode(status); - if (!response) return false; if (response.$ref) return findSchemaDefinition(response.$ref, oas).content; diff --git a/packages/api-explorer/src/ResponseTabs.jsx b/packages/api-explorer/src/ResponseTabs.jsx index a83e2a416..c40819afd 100644 --- a/packages/api-explorer/src/ResponseTabs.jsx +++ b/packages/api-explorer/src/ResponseTabs.jsx @@ -17,11 +17,16 @@ class ResponseTabs extends React.Component { componentDidMount() { const { operation } = this.props; - operation.getResponseExamples().then(examples => { - this.setState({ - responseExamples: examples, + operation + .getResponseExamples() + .then(examples => { + this.setState({ + responseExamples: examples, + }); + }) + .catch(() => { + // If we fail to generate examples for whatever reason fail silently. }); - }); } render() { diff --git a/packages/api-explorer/src/block-types/CodeElement.jsx b/packages/api-explorer/src/block-types/CodeElement.jsx index 33ec09b67..e0a60b71d 100644 --- a/packages/api-explorer/src/block-types/CodeElement.jsx +++ b/packages/api-explorer/src/block-types/CodeElement.jsx @@ -36,16 +36,19 @@ class CodeElement extends React.PureComponent { render() { const { activeTab, code, dark } = this.props; + const props = { + tokenizeVariables: true, + }; + + if (dark) { + props.customTheme = 'tomorrow-night'; + } + return (
(this.state.el ? this.state.el.textContent : '')} />
-          
-            {syntaxHighlighter(code.code, code.language, {
-              dark,
-              tokenizeVariables: true,
-            })}
-          
+          {syntaxHighlighter(code.code, code.language, props)}
         
); diff --git a/packages/api-explorer/src/form-components/CodeEditor.jsx b/packages/api-explorer/src/form-components/CodeEditor.jsx new file mode 100644 index 000000000..6899a57da --- /dev/null +++ b/packages/api-explorer/src/form-components/CodeEditor.jsx @@ -0,0 +1,33 @@ +const PropTypes = require('prop-types'); + +const syntaxHighlighter = + typeof window !== 'undefined' ? require('@readme/syntax-highlighter/dist/index.js').default : () => {}; + +function CodeEditor(props) { + const { code, onChange } = props; + + return syntaxHighlighter( + code, + 'json', + { + editable: true, + dark: false, + }, + { + onChange: (editor, data, value) => { + return onChange(value); + }, + } + ); +} + +CodeEditor.propTypes = { + code: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; + +function createCodeEditor() { + return CodeEditor; +} + +module.exports = createCodeEditor; diff --git a/packages/api-explorer/src/index.jsx b/packages/api-explorer/src/index.jsx index 01f4b77e1..fe3680ec9 100644 --- a/packages/api-explorer/src/index.jsx +++ b/packages/api-explorer/src/index.jsx @@ -234,6 +234,7 @@ class ApiExplorer extends React.Component { auth={this.state.auth} baseUrl={this.props.baseUrl.replace(/\/$/, '')} doc={doc} + enableRequestBodyJsonEditor={this.props.enableRequestBodyJsonEditor} flags={this.props.flags} group={this.state.group} groups={this.groups} @@ -278,6 +279,7 @@ ApiExplorer.propTypes = { baseUrl: PropTypes.string, docs: PropTypes.arrayOf(PropTypes.object).isRequired, dontLazyLoad: PropTypes.bool, + enableRequestBodyJsonEditor: PropTypes.bool, flags: PropTypes.shape({ correctnewlines: PropTypes.bool, rdmdCompatibilityMode: PropTypes.bool, @@ -316,6 +318,7 @@ ApiExplorer.propTypes = { ApiExplorer.defaultProps = { baseUrl: '/', dontLazyLoad: false, + enableRequestBodyJsonEditor: false, flags: { correctnewlines: false, rdmdCompatibilityMode: false, diff --git a/packages/api-explorer/src/lib/content-type-is-json.js b/packages/api-explorer/src/lib/content-type-is-json.js deleted file mode 100644 index 315904b07..000000000 --- a/packages/api-explorer/src/lib/content-type-is-json.js +++ /dev/null @@ -1,6 +0,0 @@ -function contentTypeIsJson(contentType) { - const jsonContentTypes = ['application/json', 'application/x-json', 'text/json', 'text/x-json', '+json', '*/*']; - return jsonContentTypes.some(ct => contentType.includes(ct)); -} - -module.exports = contentTypeIsJson; diff --git a/packages/api-explorer/src/lib/parse-response.js b/packages/api-explorer/src/lib/parse-response.js index e889965be..ab885f56c 100644 --- a/packages/api-explorer/src/lib/parse-response.js +++ b/packages/api-explorer/src/lib/parse-response.js @@ -1,5 +1,5 @@ const { stringify } = require('querystring'); -const contentTypeIsJson = require('./content-type-is-json'); +const { matchesMimeType } = require('oas/tooling/utils'); function getQuerystring(har) { // Converting [{ name: a, value: '123456' }] => { a: '123456' } so we can use querystring.stringify @@ -12,7 +12,7 @@ function getQuerystring(har) { async function getResponseBody(response) { const contentType = response.headers.get('Content-Type'); - const isJson = contentType && contentTypeIsJson(contentType); + const isJson = contentType && (matchesMimeType.json(contentType) || matchesMimeType.wildcard(contentType)); // We have to clone it before reading, just incase // we cannot parse it as JSON later, then we can diff --git a/packages/api-explorer/style/main.scss b/packages/api-explorer/style/main.scss index 361a34081..c3028e689 100644 --- a/packages/api-explorer/style/main.scss +++ b/packages/api-explorer/style/main.scss @@ -1,6 +1,11 @@ -@import './ErrorBoundary.scss'; +@import "./ErrorBoundary.scss"; @import "./markdown-overrides.scss"; +:root { + --apie-tab-color-focus: #4f5a66; // graphite + --apie-tab-color: #939eae; // shale +} + /* Add margin to left- and right-sides of RJSF component. */ form.rjsf { margin: 0% 3%; @@ -23,12 +28,104 @@ form form.rjsf { } /* Style table header. */ +.param-type { + position: relative; + + /* override tab ui */ + .Tabs { + + &-list { + border: 0; + position: absolute; + height: 34px; + right: 30px; // match padding + top: 0; + width: auto; + } + + &-listItem { + color: var(--apie-tab-color); + font-size: 14px; + font-weight: 600; + + &:hover, + &:active, + &:focus { + background: none; + box-shadow: none; + } + + &:hover, + &:focus { + color: var(--apie-tab-color-focus); + } + + &:active { + color: var(--project-color-primary, #118cfd); + } + + &_active { + box-shadow: inset 0 -3px 0 var(--project-color-primary, #118cfd) !important; + } + } + } + + /* override CodeEditor ui */ + .CodeEditor, + .CodeEditor-Toolbar { + margin-left: 30px; + margin-right: 30px; + } + + .CodeEditor { + margin-top: 20px; + } + + .CodeEditor-Toolbar { + align-items: center; + border-radius: 0 0 6px 6px; + border: 1px solid #ddd; + border-top-color: #eee; + color: #e95f6a; // red + display: flex; + font-weight: 600; + justify-content: space-between; + margin-bottom: 20px; + padding: 5px 5px 5px 10px; + + &-Error { + font-size: 0.86957em; // match sm buttons + + &-Icon { + margin-right: 5px; + } + } + } + + .CodeMirror { + border: 1px solid #ddd; + border-bottom: 0; + border-radius: 6px 6px 0 0; + font-size: 12px; + padding-top: 5px; + } + + /* invalid state */ + &_invalidJson { + .CodeMirror, + .CodeEditor-Toolbar { + border-color: #e95f6a; // red + } + } +} + .param-type-header { + border-bottom: 1px solid #ddd; color: #aeaeae; font-size: 14px; - text-transform: uppercase; - border-bottom: 1px solid #ddd; + height: 34px; margin-left: 3%; + text-transform: uppercase; } /* Style param labels. */ @@ -805,3 +902,57 @@ form.rjsf { max-height: 80vh; overflow: auto; } + +/* disabled Try It button (when JSON is invalid) */ +#hub-reference .hub-api .api-definition .api-try-it-out.invalid, +#hub-reference .hub-api .api-definition .api-try-it-out.invalid:hover, +#hub-reference .hub-api .api-definition .api-try-it-out.invalid:active, +#hub-reference .hub-api .api-definition .api-try-it-out.invalid:focus { + background: transparent; + border-color: transparent; + color: #e95f6a !important; + cursor: not-allowed; + transition: none; +} + +#hub-reference .hub-api .api-definition .api-try-it-out.invalid:hover, +#hub-reference .hub-api .api-definition .api-try-it-out.invalid:active, +#hub-reference .hub-api .api-definition .api-try-it-out.invalid:focus { + + .api-try-it-out-popover { + opacity: 1; + pointer-events: auto; + transform: none; + } +} + +.api-try-it-out-popover { + background: #fff; + border-radius: 6px; + box-shadow: 0 1px 2px rgba(#000, 0.1), 0 10px 30px rgba(#000, 0.1); + color: #384248; + cursor: default; + line-height: 1.2; + opacity: 0; + padding: 20px; + pointer-events: none; + position: absolute; + right: 0; + top: 30px; + transform: translateY(-5px); + transition: opacity 0.15s cubic-bezier(0.16, 1, 0.3, 1), transform 0.15s cubic-bezier(0.16, 1, 0.3, 1); + white-space: normal; + width: 220px; + word-break: break-word; + z-index: 10; + + &-h1 { + font-size: 18px; + margin-bottom: 10px; + margin-top: 0; + } + + &-description { + margin-bottom: 15px; + } +} diff --git a/packages/markdown-magic/components/Code.jsx b/packages/markdown-magic/components/Code.jsx index c840a584d..ee091880c 100644 --- a/packages/markdown-magic/components/Code.jsx +++ b/packages/markdown-magic/components/Code.jsx @@ -8,7 +8,7 @@ function Code({ className, children }) { return ( - {syntaxHighlighter(children[0], language, { tokenizeVariables: true })} + {syntaxHighlighter(children[0], language, { tokenizeVariables: true, customTheme: 'tomorrow-night' })} ); } diff --git a/packages/oas-to-snippet/package-lock.json b/packages/oas-to-snippet/package-lock.json index f37356260..8d61245f9 100644 --- a/packages/oas-to-snippet/package-lock.json +++ b/packages/oas-to-snippet/package-lock.json @@ -1463,9 +1463,9 @@ "dev": true }, "@readme/syntax-highlighter": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@readme/syntax-highlighter/-/syntax-highlighter-10.2.0.tgz", - "integrity": "sha512-tL+0vyJIFrsCsLGfMPoslagxn9Jh6EtElgEM9qUrzTLfWqCToH7C1stTyqM3WDdQTRzun3lWsFRERuaFy3yxSg==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@readme/syntax-highlighter/-/syntax-highlighter-10.3.1.tgz", + "integrity": "sha512-E8W6njPCcecZaRjzK61XEA+VmpOIh8inanyxkLKiwOdJgi17Idq2qYHgxPiYOwxq8ceen8F4dA8BKGXVjG1cAQ==", "requires": { "@readme/variable": "^7.2.1", "codemirror": "^5.48.2", diff --git a/packages/oas-to-snippet/package.json b/packages/oas-to-snippet/package.json index 0118a3b78..2d050f264 100644 --- a/packages/oas-to-snippet/package.json +++ b/packages/oas-to-snippet/package.json @@ -20,7 +20,7 @@ "@readme/httpsnippet": "^2.3.1", "@readme/oas-extensions": "^9.0.0", "@readme/oas-to-har": "^9.2.2", - "@readme/syntax-highlighter": "10.2.*", + "@readme/syntax-highlighter": "^10.3.1", "httpsnippet-client-api": "^2.4.4", "oas": "^6.1.0" },