diff --git a/assets/stylesheets/sections/devdocs.scss b/assets/stylesheets/sections/devdocs.scss index 34ec49c7aa030e..00c8d56c676a0d 100644 --- a/assets/stylesheets/sections/devdocs.scss +++ b/assets/stylesheets/sections/devdocs.scss @@ -4,9 +4,7 @@ @import 'devdocs/style'; @import 'devdocs/design/style'; @import 'devdocs/design/syntax.scss'; +@import 'devdocs/gutenberg-components/style'; @import 'devdocs/gutenberg-blocks/style'; @import 'assets/stylesheets/sections/media'; - -// Gutenberg components -@import '../../../node_modules/@wordpress/components/build-style/style'; diff --git a/client/devdocs/controller.js b/client/devdocs/controller.js index f824c4e96023cd..fc9ed5aed3bbea 100644 --- a/client/devdocs/controller.js +++ b/client/devdocs/controller.js @@ -166,7 +166,7 @@ const devdocs = { // Gutenberg Components gutenbergComponents: function( context, next ) { context.primary = ( - + ); next(); }, diff --git a/client/devdocs/design/component-playground.jsx b/client/devdocs/design/component-playground.jsx index 452cc87ab257f1..376ba8c5c64115 100644 --- a/client/devdocs/design/component-playground.jsx +++ b/client/devdocs/design/component-playground.jsx @@ -44,9 +44,6 @@ class ComponentPlayground extends Component { const { section } = this.props; let scope = null; switch ( section ) { - case 'gutenberg-components': - scope = require( '@wordpress/components' ); - break; case 'gutenberg-blocks': scope = require( 'gutenberg-blocks' ); break; diff --git a/client/devdocs/design/gutenberg-components.jsx b/client/devdocs/design/gutenberg-components.jsx deleted file mode 100644 index af04ac488416ba..00000000000000 --- a/client/devdocs/design/gutenberg-components.jsx +++ /dev/null @@ -1,59 +0,0 @@ -/** @format */ - -/** - * External dependencies - */ -import React from 'react'; -import classnames from 'classnames'; -import page from 'page'; - -/** - * Internal dependencies - */ -import Collection from 'devdocs/design/search-collection'; -import DocumentHead from 'components/data/document-head'; -import HeaderCake from 'components/header-cake'; -import Main from 'components/main'; -import ReadmeViewer from 'components/readme-viewer'; -import { slugToCamelCase } from 'devdocs/docs-example/util'; - -/** - * Docs examples - */ -import { Button } from 'gutenberg-components/examples'; - -export default class extends React.Component { - state = { filter: '' }; - - backToAll = () => { - page( '/devdocs/gutenberg-components/' ); - }; - - render() { - const { component } = this.props; - const { filter } = this.state; - - const className = classnames( 'devdocs', 'devdocs__gutenberg-components', { - 'is-single': component, - 'is-list': ! component, - } ); - - return ( -
- - - { component ? ( - - { slugToCamelCase( component ) } - - ) : ( - - ) } - - -
- ); - } -} diff --git a/client/devdocs/design/style.scss b/client/devdocs/design/style.scss index f526c878d0b699..dd88e4d0abbb24 100644 --- a/client/devdocs/design/style.scss +++ b/client/devdocs/design/style.scss @@ -13,7 +13,7 @@ .design__playground { margin: -24px; - @include breakpoint( '>960px') { + @include breakpoint( '>960px' ) { margin: -32px; } } @@ -27,25 +27,25 @@ box-sizing: border-box; color: $alert-red; position: sticky; - top: 0; + top: 0; } .react-live-error, .design__preview { padding: 24px; - @include breakpoint( '>960px') { + @include breakpoint( '>960px' ) { padding: 32px; } } .design__preview { position: relative; - @include breakpoint( '>960px') { + @include breakpoint( '>960px' ) { position: static; } } -@include breakpoint( '>960px') { +@include breakpoint( '>960px' ) { .design__playground { display: flex; height: calc( 100vh - 47px ); @@ -71,8 +71,8 @@ .design__component-playground-clipboard { padding: 0; position: absolute; - right: 10px; - top: 10px; + right: 10px; + top: 10px; } .design__component-playground-show-code { diff --git a/client/devdocs/design/test/component-playground.js b/client/devdocs/design/test/component-playground.js index 159e72ff88b383..c2898b82d6e1f2 100644 --- a/client/devdocs/design/test/component-playground.js +++ b/client/devdocs/design/test/component-playground.js @@ -17,7 +17,6 @@ import ComponentPlayground from '../component-playground'; jest.mock( 'devdocs/design/playground-scope', () => 'PlaygroundScope' ); jest.mock( 'gutenberg-blocks', () => 'GutenbergBlocks' ); -jest.mock( '@wordpress/components', () => 'GutenbergComponents' ); describe( 'ComponentPlayground', () => { test( 'LiveProvider should use the components scope by default', () => { @@ -28,18 +27,6 @@ describe( 'ComponentPlayground', () => { expect( liveProvider.props().scope ).toBe( 'PlaygroundScope' ); } ); - test( - 'LiveProvider should use the Gutenberg components scope when section is Gutenberg' + - ' components', - () => { - const wrapper = shallow( - - ); - const liveProvider = wrapper.find( LiveProvider ); - expect( liveProvider.props().scope ).toBe( 'GutenbergComponents' ); - } - ); - test( 'LiveProvider should use the Gutenberg blocks scope when section is Gutenberg blocks', () => { const wrapper = shallow( diff --git a/client/devdocs/gutenberg-components/example.jsx b/client/devdocs/gutenberg-components/example.jsx new file mode 100644 index 00000000000000..f135f45bc06bd9 --- /dev/null +++ b/client/devdocs/gutenberg-components/example.jsx @@ -0,0 +1,77 @@ +/** @format */ + +/** + * External dependencies + */ +import React from 'react'; +import * as components from '@wordpress/components'; +import { withState } from '@wordpress/compose'; +import { getSettings } from '@wordpress/date'; +import { addFilter } from '@wordpress/hooks'; +import { LiveError, LivePreview, LiveProvider } from 'react-live'; +import request from 'superagent'; +import codeBlocks from 'gfm-code-blocks'; +import classnames from 'classnames'; +import { kebabCase } from 'lodash'; +import PropTypes from 'prop-types'; + +class Example extends React.Component { + state = { + code: null, + }; + + componentDidMount() { + this.getCode(); + } + + async getReadme() { + const readmeFilePath = `/node_modules/@wordpress/components/src/${ + this.props.readmeFilePath + }/README.md`; + + const { text } = await request.get( '/devdocs/service/content' ).query( { + path: readmeFilePath, + format: 'markdown', + } ); + return text; + } + + async getCode() { + const readme = await this.getReadme(); + + // Example to render is the first jsx code block that appears in the readme + let code = codeBlocks( readme ).find( block => 'jsx' === block.lang ).code; + + // react-live cannot resolve imports in real time, so we get rid of them + // (dependencies will be injected via the scope property). + code = code.replace( /^.*import.*$/gm, '' ); + + code = `${ code } render( <${ this.props.render } /> );`; + + this.setState( { code } ); + } + + render() { + const { code } = this.state; + const scope = { + ...components, + withState, + getSettings, + PropTypes, + addFilter, + }; + const className = classnames( + 'devdocs__gutenberg-components-example', + `devdocs__gutenberg-components-example--${ kebabCase( this.props.component ) }` + ); + + return code ? ( + + + + + ) : null; + } +} + +export default Example; diff --git a/client/devdocs/gutenberg-components/examples.json b/client/devdocs/gutenberg-components/examples.json new file mode 100644 index 00000000000000..b0e171c3b4e121 --- /dev/null +++ b/client/devdocs/gutenberg-components/examples.json @@ -0,0 +1,90 @@ +[ + { "component": "Autocomplete" }, + { "component": "BaseControl" }, + { "component": "Button" }, + { "component": "ButtonGroup" }, + { "component": "CheckboxControl" }, + { "component": "ClipboardButton" }, + { "component": "ColorIndicator" }, + { "component": "ColorPalette" }, + { "component": "Dashicon" }, + { "component": "DateTimePicker", "readmeFilePath": "date-time" }, + { "component": "Disabled" }, + { "component": "Draggable" }, + { "component": "DropZone" }, + { "component": "Dropdown" }, + { "component": "DropdownMenu" }, + { "component": "ExternalLink" }, + { "component": "FocusableIframe" }, + { "component": "FontSizePicker" }, + { "component": "FormFileUpload" }, + { "component": "FormToggle" }, + { "component": "FormTokenField" }, + { "component": "IconButton" }, + { "component": "KeyboardShortcuts" }, + { "component": "MenuGroup" }, + { "component": "MenuItem" }, + { "component": "MenuItemsChoice" }, + { "component": "Modal" }, + { + "component": "navigateRegions", + "readmeFilePath": "higher-order/navigate-regions", + "render": "MyComponentWithNavigateRegions" + }, + { "component": "NavigableContainer" }, + { "component": "Notice" }, + { "component": "Panel" }, + { "component": "Placeholder" }, + { "component": "Popover" }, + { "component": "QueryControls" }, + { "component": "RadioControl" }, + { "component": "RangeControl" }, + { "component": "ResponsiveWrapper" }, + { "component": "SandBox", "readmeFilePath": "sandbox" }, + { "component": "ScrollLock" }, + { "component": "SelectControl" }, + { "component": "SlotFillProvider", "readmeFilePath": "slot-fill" }, + { "component": "Spinner" }, + { "component": "TabPanel" }, + { "component": "TextControl" }, + { "component": "TextareaControl" }, + { "component": "ToggleControl" }, + { "component": "Toolbar" }, + { "component": "Tooltip" }, + { "component": "TreeSelect" }, + { + "component": "withConstrainedTabbing", + "readmeFilePath": "higher-order/with-constrained-tabbing", + "render": "MyComponentWithConstrainedTabbing" + }, + { + "component": "withFallbackStyles", + "readmeFilePath": "higher-order/with-fallback-styles", + "render": "MyComponentWithFallbackStyles" + }, + { + "component": "withFilters", + "readmeFilePath": "higher-order/with-filters", + "render": "MyComponentWithFilters" + }, + { + "component": "withFocusOutside", + "readmeFilePath": "higher-order/with-focus-outside", + "render": "MyComponentWithFocusOutside" + }, + { + "component": "withFocusReturn", + "readmeFilePath": "higher-order/with-focus-return", + "render": "MyComponentWithFocusReturn" + }, + { + "component": "withNotices", + "readmeFilePath": "higher-order/with-notices", + "render": "MyComponentWithNotices" + }, + { + "component": "withSpokenMessages", + "readmeFilePath": "higher-order/with-spoken-messages", + "render": "MyComponentWithSpokenMessages" + } +] diff --git a/client/devdocs/gutenberg-components/index.jsx b/client/devdocs/gutenberg-components/index.jsx new file mode 100644 index 00000000000000..b62394d81d99d8 --- /dev/null +++ b/client/devdocs/gutenberg-components/index.jsx @@ -0,0 +1,97 @@ +/** @format */ + +/** + * External dependencies + */ +import React from 'react'; +import classnames from 'classnames'; +import page from 'page'; +import { get, trim } from 'lodash'; + +/** + * Internal dependencies + */ +import Collection from 'devdocs/design/search-collection'; +import DocumentHead from 'components/data/document-head'; +import HeaderCake from 'components/header-cake'; +import Main from 'components/main'; +import ReadmeViewer from 'components/readme-viewer'; +import SearchCard from 'components/search-card'; +import { camelCaseToSlug, slugToCamelCase } from 'devdocs/docs-example/util'; +import GutenbergComponentExample from './example'; +import examples from './examples.json'; + +const getExampleData = ( example ) => { + const componentName = get( example, 'component' ); + const readmeFilePath = get( example, 'readmeFilePath', camelCaseToSlug( componentName ) ); + const render = get( example, 'render', `My${ componentName }` ); + + return { + componentName, + readmeFilePath, + render, + }; +}; + +export default class extends React.Component { + state = { filter: '' }; + + backToAll = () => { + page( '/devdocs/gutenberg-components/' ); + }; + + onSearch = term => { + this.setState( { filter: trim( term || '' ).toLowerCase() } ); + }; + + render() { + const { component } = this.props; + const { filter } = this.state; + + const className = classnames( 'devdocs', 'devdocs__gutenberg-components', { + 'is-single': component, + 'is-list': ! component, + } ); + + return ( +
+ + + { component ? ( + + { slugToCamelCase( component ) } + + ) : ( +
+ + +
+ ) } + + + { examples.map( example => { + const { + componentName, + readmeFilePath, + render, + } = getExampleData( example ); + return ( + + ); + } ) } + +
+ ); + } +} diff --git a/client/devdocs/gutenberg-components/style.scss b/client/devdocs/gutenberg-components/style.scss new file mode 100644 index 00000000000000..003bd215923b3d --- /dev/null +++ b/client/devdocs/gutenberg-components/style.scss @@ -0,0 +1,6 @@ +@import '../../../node_modules/@wordpress/components/build-style/style'; + +// Styles the contenteditable div included in the Gutenberg Autocomplete component example as an input +.devdocs__gutenberg-components-example--autocomplete [contenteditable] { + @extend %form-field; +} diff --git a/client/devdocs/gutenberg-components/test/example.jsx b/client/devdocs/gutenberg-components/test/example.jsx new file mode 100644 index 00000000000000..b7075178e35e54 --- /dev/null +++ b/client/devdocs/gutenberg-components/test/example.jsx @@ -0,0 +1,75 @@ +/** + * @format + * @jest-environment jsdom + */ + +/** + * External dependencies + */ +import React from 'react'; +import { shallow } from 'enzyme'; +import request from 'superagent'; + +/** + * Internal dependencies + */ +import GutenbergComponentExample from '../example'; + +jest.mock( 'superagent', () => ( { + get: jest.fn().mockReturnThis(), + query: jest.fn().mockResolvedValue( { text: 'foo' } ), +} ) ); + +describe( 'GutenbergComponentExample', () => { + test( 'should retrieve the code when mounted', () => { + const wrapper = shallow( , { + disableLifecycleMethods: true, + } ); + wrapper.instance().getCode = jest.fn(); + wrapper.instance().componentDidMount(); + expect( wrapper.instance().getCode ).toHaveBeenCalled(); + } ); + + test( 'should retrieve the README file from node_modules', async () => { + const wrapper = shallow( , { + disableLifecycleMethods: true, + } ); + await wrapper.instance().getReadme(); + expect( request.get ).toHaveBeenCalledWith( '/devdocs/service/content' ); + expect( request.query ).toHaveBeenCalledWith( { + path: '/node_modules/@wordpress/components/src/bar/README.md', + format: 'markdown', + } ); + } ); + + test( 'should get the code from the first jsx block', async () => { + const wrapper = shallow( , { + disableLifecycleMethods: true, + } ); + const mockedReadme = '# test\n```jsx\nmy code\n```\n```jsx\ntest\n```'; + wrapper.instance().getReadme = jest.fn().mockResolvedValue( mockedReadme ); + await wrapper.instance().getCode(); + expect( wrapper.state().code ).toContain( 'my code' ); + expect( wrapper.state().code ).not.toContain( 'test' ); + } ); + + test( 'should remove the imports from the example', async () => { + const wrapper = shallow( , { + disableLifecycleMethods: true, + } ); + const mockedReadme = '```jsx\nimport test\nmy code\n```'; + wrapper.instance().getReadme = jest.fn().mockResolvedValue( mockedReadme ); + await wrapper.instance().getCode(); + expect( wrapper.state().code ).not.toContain( 'import test' ); + } ); + + test( 'should render the given component', async () => { + const wrapper = shallow( , { + disableLifecycleMethods: true, + } ); + const mockedReadme = '```jsx\nmy code\n```'; + wrapper.instance().getReadme = jest.fn().mockResolvedValue( mockedReadme ); + await wrapper.instance().getCode(); + expect( wrapper.state().code ).toContain( 'render( )' ); + } ); +} ); diff --git a/client/devdocs/design/test/gutenberg-components.js b/client/devdocs/gutenberg-components/test/index.js similarity index 84% rename from client/devdocs/design/test/gutenberg-components.js rename to client/devdocs/gutenberg-components/test/index.js index ff280129c60429..4de95c2657cfe2 100644 --- a/client/devdocs/design/test/gutenberg-components.js +++ b/client/devdocs/gutenberg-components/test/index.js @@ -13,10 +13,12 @@ import page from 'page'; /** * Internal dependencies */ -import GutenbergComponents from '../gutenberg-components'; +import GutenbergComponents from '../'; import HeaderCake from 'components/header-cake'; import ReadmeViewer from 'components/readme-viewer'; import Collection from 'devdocs/design/search-collection'; +import GutenbergComponentExample from '../example'; +import examplesList from '../examples.json'; jest.mock( 'page' ); @@ -26,26 +28,24 @@ describe( 'GutenbergComponents', () => { const headerCake = wrapper.find( HeaderCake ); const readmeViewer = wrapper.find( ReadmeViewer ); const collection = wrapper.find( Collection ); + const examples = wrapper.find( GutenbergComponentExample ); expect( wrapper.hasClass( 'is-list' ) ).toBe( true ); expect( headerCake ).toHaveLength( 0 ); expect( readmeViewer ).toHaveLength( 1 ); expect( readmeViewer.props().readmeFilePath ).toBe( '/client/devdocs/gutenberg-components/README.md' ); - expect( collection ).toHaveLength( 1 ); - expect( collection.children() ).toHaveLength( 1 ); + expect( collection.children() ).toEqual( examples ); + expect( examples ).toHaveLength( examplesList.length ); } ); test( 'should render a single component when a component is given', () => { const wrapper = shallow( ); const headerCake = wrapper.find( HeaderCake ); const readmeViewer = wrapper.find( ReadmeViewer ); - const collection = wrapper.find( Collection ); expect( wrapper.hasClass( 'is-single' ) ).toBe( true ); expect( headerCake ).toHaveLength( 1 ); expect( readmeViewer ).toHaveLength( 0 ); - expect( collection ).toHaveLength( 1 ); - expect( collection.children() ).toHaveLength( 1 ); } ); test( 'should go back when clicking in HeaderCake', () => { diff --git a/client/devdocs/index.js b/client/devdocs/index.js index 48da16b88a2c87..334b71be0bb793 100644 --- a/client/devdocs/index.js +++ b/client/devdocs/index.js @@ -72,15 +72,15 @@ export default function() { page( '/devdocs/start', controller.pleaseLogIn, makeLayout, clientRender ); page( '/devdocs/welcome', controller.sidebar, controller.welcome, makeLayout, clientRender ); - page( - '/devdocs/gutenberg-components/:component?', - controller.sidebar, - controller.gutenbergComponents, - makeLayout, - clientRender - ); - if ( config.isEnabled( 'devdocs/gutenberg-blocks' ) ) { + page( + '/devdocs/gutenberg-components/:component?', + controller.sidebar, + controller.gutenbergComponents, + makeLayout, + clientRender + ); + page( '/devdocs/gutenberg-blocks/:block*', controller.sidebar, diff --git a/client/devdocs/sidebar.jsx b/client/devdocs/sidebar.jsx index 7e02123fad925b..a934fe9b089fd9 100644 --- a/client/devdocs/sidebar.jsx +++ b/client/devdocs/sidebar.jsx @@ -104,13 +104,15 @@ export default class DevdocsSidebar extends React.PureComponent { link="/devdocs/blocks" selected={ this.isItemSelected( '/devdocs/blocks', false ) } /> - + { isEnabled( 'devdocs/gutenberg-blocks' ) && ( + + ) } { isEnabled( 'devdocs/gutenberg-blocks' ) && ( - - - - - - - - - - - ), - }; - - render() { - return this.props.exampleCode; - } -} diff --git a/client/gutenberg-components/examples.js b/client/gutenberg-components/examples.js deleted file mode 100644 index 30e3d6c50d0504..00000000000000 --- a/client/gutenberg-components/examples.js +++ /dev/null @@ -1 +0,0 @@ -export Button from './button/docs/example'; diff --git a/docs/components.md b/docs/components.md index c3fca5c9381866..a403eb08c57554 100644 --- a/docs/components.md +++ b/docs/components.md @@ -10,8 +10,6 @@ You will encounter the following types of components in Calypso: * [UI components](../client/components/README.md) (UI primitives) * [Blocks](../client/blocks/README.md) (components which are connected to state, or otherwise directly represent application entities) -* [Gutenberg components](../client/gutenberg-components/README.md) (components included in the -[`@wordpress/components`](https://www.npmjs.com/package/@wordpress/components) package) * [Query components](./our-approach-to-data.md#query-components) (which handle data querying but don’t render anything) * Higher-order components (which encapsulate and provide functionality) * Section components (which are domain specific and not meant to be reused) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 043e3eb059310c..2db5a8a3ca5434 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -6952,6 +6952,19 @@ "safe-buffer": "^5.1.1" } }, + "gfm-code-block-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gfm-code-block-regex/-/gfm-code-block-regex-1.0.0.tgz", + "integrity": "sha1-u4PH1ihOa1ty+gIZilisDSViFdI=" + }, + "gfm-code-blocks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gfm-code-blocks/-/gfm-code-blocks-1.0.0.tgz", + "integrity": "sha1-YU0hBZuETGu8nViMCJslxOi8zw0=", + "requires": { + "gfm-code-block-regex": "^1.0.0" + } + }, "glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", diff --git a/package.json b/package.json index a28c99c75b407e..740f9f32f4197c 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@wordpress/compose": "1.0.4", "@wordpress/core-data": "1.1.1", "@wordpress/data": "1.2.1", + "@wordpress/date": "1.0.3", "@wordpress/deprecated": "1.0.3", "@wordpress/editor": "2.0.1", "@wordpress/element": "1.0.4", @@ -105,6 +106,7 @@ "flux": "3.1.3", "fuse.js": "3.2.1", "get-video-id": "3.1.0", + "gfm-code-blocks": "1.0.0", "globby": "8.0.1", "gridicons": "3.0.1", "gzip-size": "5.0.0", diff --git a/server/devdocs/index.js b/server/devdocs/index.js index 91b4503a88ae12..b30e2ad60eeae8 100644 --- a/server/devdocs/index.js +++ b/server/devdocs/index.js @@ -209,9 +209,11 @@ module.exports = function() { response.json( listDocs( files.split( ',' ) ) ); } ); - // return the HTML content of a document (assumes that the document is in markdown format) + // return the content of a document in the given format (assumes that the document is in + // markdown format) app.get( '/devdocs/service/content', ( request, response ) => { let path = request.query.path; + const format = request.query.format || 'html'; if ( ! path ) { response @@ -237,7 +239,7 @@ module.exports = function() { const fileContents = fs.readFileSync( path, { encoding: 'utf8' } ); - response.send( marked( fileContents ) ); + response.send( 'html' === format ? marked( fileContents ) : fileContents ); } ); // return json for the components usage stats