React.Context was overkill for this purpose, not using it

Also using Gatsby's built-in location property
This commit is contained in:
Jeff Avallone 2019-01-12 21:47:36 -05:00
parent 2d754227b1
commit 13cfcca85e
8 changed files with 236 additions and 191 deletions

View File

@ -1,11 +1,62 @@
import React from 'react';
import PropTypes from 'prop-types';
import * as Sentry from '@sentry/browser';
import URLSearchParams from '@ungap/url-search-params';
import { AppContextProvider } from 'components/AppContext';
import Form from 'components/Form';
import Loader from 'components/Loader';
import Message from 'components/Message';
const toUrl = params => new URLSearchParams(params).toString();
const createSvgLink = async ({ svg }) => {
try {
const type = 'image/svg+xml';
const blob = new Blob([svg], { type });
return {
url: URL.createObjectURL(blob),
label: 'Download SVG',
filename: 'image.svg',
type
};
}
catch (e) {
console.error(e); // eslint-disable-line no-console
}
};
const createPngLink = async ({ svg, width, height }) => {
try {
const type = 'image/png';
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const loader = new Image();
loader.width = canvas.width = Number(width) * 2;
loader.height = canvas.height = Number(height) * 2;
await new Promise(resolve => {
loader.onload = resolve;
loader.src = 'data:image/svg+xml,' + encodeURIComponent(svg);
});
context.drawImage(loader, 0, 0, loader.width, loader.height);
const blob = await new Promise(resolve => canvas.toBlob(resolve, type));
return {
url: URL.createObjectURL(blob),
label: 'Download PNG',
filename: 'image.png',
type
};
}
catch (e) {
console.error(e); // eslint-disable-line no-console
}
};
class App extends React.PureComponent {
state = {
loading: false,
@ -13,15 +64,43 @@ class App extends React.PureComponent {
Render: null
}
handleRender = async ({ syntax, expr }) => {
componentDidMount() {
if (this.props.expr) {
this.handleRender();
}
}
componentDidUpdate(prevProps) {
const { syntax, expr } = this.props;
if (syntax !== prevProps.syntax || expr !== prevProps.expr) {
this.handleRender();
}
}
handleSubmit = ({ syntax, expr }) => {
if (expr) {
document.location.hash = toUrl({ syntax, expr });
}
}
handleRender = async () => {
const { syntax, expr } = this.props;
this.setState({
syntax,
expr,
loading: true,
loading: false,
loadingError: null,
Render: null
});
if (!expr) {
return;
}
this.setState({
loading: true
});
try {
const Render = await import(
/* webpackChunkName: "render-[index]" */
@ -51,18 +130,51 @@ class App extends React.PureComponent {
handleRetry = event => {
event.preventDefault();
this.handleRender(this.state);
this.handleRender();
}
handleSvgMarkup = async ({ svg, width, height }) => {
if (svg !== this.state.svg) {
this.setState({
svg,
svgLink: await createSvgLink({ svg }),
pngLink: await createPngLink({ svg, width, height })
});
}
}
render() {
const {
syntax,
expr,
permalinkUrl
} = this.props;
const {
loading,
loadingError,
Render
Render,
svgLink,
pngLink
} = this.state;
return <AppContextProvider onExpressionChange={ this.handleRender }>
<Form />
const formProps = {
onSubmit: this.handleSubmit,
syntax,
expr,
actions: {
permalinkUrl,
svgLink,
pngLink
}
};
const renderProps = {
onRender: this.handleSvgMarkup,
syntax,
expr
};
return <>
<Form { ...formProps } />
{ loading && <Loader /> }
@ -71,9 +183,15 @@ class App extends React.PureComponent {
<a href="#retry" onClick={ this.handleRetry }>Retry</a>
</Message> }
{ Render && <Render /> }
</AppContextProvider>;
{ Render && <Render { ...renderProps } /> }
</>;
}
}
App.propTypes = {
syntax: PropTypes.string,
expr: PropTypes.string,
permalinkUrl: PropTypes.string
};
export default App;

View File

@ -1,149 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import URLSearchParams from '@ungap/url-search-params';
const AppContext = React.createContext();
const toUrl = params => new URLSearchParams(params).toString();
const readURLHash = () => {
const query = document.location.hash.slice(1);
const params = new URLSearchParams(query);
if (params.get('syntax')) {
return {
syntax: params.get('syntax'),
expr: params.get('expr')
};
} else {
// Assuming old-style URL
return {
syntax: 'js',
expr: query
};
}
};
const createSvgLink = async ({ svg }) => {
try {
const type = 'image/svg+xml';
const blob = new Blob([svg], { type });
return {
url: URL.createObjectURL(blob),
label: 'Download SVG',
filename: 'image.svg',
type
};
}
catch (e) {
console.error(e); // eslint-disable-line no-console
}
};
const createPngLink = async ({ svg, width, height }) => {
try {
const type = 'image/png';
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const loader = new Image();
loader.width = canvas.width = Number(width) * 2;
loader.height = canvas.height = Number(height) * 2;
await new Promise(resolve => {
loader.onload = resolve;
loader.src = 'data:image/svg+xml,' + encodeURIComponent(svg);
});
context.drawImage(loader, 0, 0, loader.width, loader.height);
const blob = await new Promise(resolve => canvas.toBlob(resolve, type));
return {
url: URL.createObjectURL(blob),
label: 'Download PNG',
filename: 'image.png',
type
};
}
catch (e) {
console.error(e); // eslint-disable-line no-console
}
};
class AppContextProvider extends React.PureComponent {
state = {}
mutations = {
setSvgMarkup: async ({ svg, width, height }) => {
if (svg !== this.state.svg) {
this.setState({
svg,
svgLink: await createSvgLink({ svg }),
pngLink: await createPngLink({ svg, width, height })
});
}
},
renderExpr: ({ syntax, expr }) => {
if (expr) {
document.location.hash = toUrl({ syntax, expr });
}
}
}
componentDidMount() {
// Gatsby doesn't have document.location, so readURLHash can't be called
// until here
this.setState(readURLHash());
window.addEventListener('hashchange', this.handleHashChange);
this.handleHashChange();
}
componentWillUnmount() {
window.removeEventListener('hashchange', this.handleHashChange);
}
handleHashChange = () => {
const { expr, syntax } = readURLHash();
if (!expr) {
return;
}
this.setState({
syntax,
expr,
permalinkUrl: document.location.toString()
});
if (this.props.onExpressionChange) {
this.props.onExpressionChange({ syntax, expr });
}
}
render() {
const { children } = this.props;
const context = {
...this.state,
...this.mutations
};
return <AppContext.Provider value={ context }>
{ children }
</AppContext.Provider>;
}
}
AppContextProvider.propTypes = {
onExpressionChange: PropTypes.func,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
};
export { AppContextProvider };
export default AppContext;

View File

@ -1,10 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import ExpandIcon from 'react-feather/dist/icons/chevrons-down';
import style from './style.module.css';
import AppContext from 'components/AppContext';
import FormActions from 'components/FormActions';
const syntaxList = [
@ -13,20 +13,9 @@ const syntaxList = [
];
class Form extends React.PureComponent {
static contextType = AppContext
state = {
expr: this.context.expr,
syntax: this.context.syntax
}
componentDidUpdate() {
if (this.state.expr === undefined && this.state.syntax === undefined) {
this.setState({
syntax: this.context.syntax,
expr: this.context.expr
});
}
expr: this.props.expr,
syntax: this.props.syntax
}
handleSubmit = event => {
@ -34,7 +23,7 @@ class Form extends React.PureComponent {
const { expr, syntax } = this.state;
this.context.renderExpr({ expr, syntax });
this.props.onSubmit({ expr, syntax });
}
handleKeyPress = event => {
@ -48,6 +37,9 @@ class Form extends React.PureComponent {
})
render() {
const {
actions
} = this.props;
const { expr, syntax } = this.state;
return <div className={ style.form }>
@ -71,10 +63,17 @@ class Form extends React.PureComponent {
</select>
<ExpandIcon />
</div>
<FormActions />
<FormActions { ...actions } />
</form>
</div>;
}
}
Form.propTypes = {
expr: PropTypes.string,
syntax: PropTypes.string,
actions: PropTypes.object,
onSubmit: PropTypes.func.isRequired
};
export default Form;

View File

@ -1,15 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import DownloadIcon from 'react-feather/dist/icons/download';
import LinkIcon from 'react-feather/dist/icons/link';
import style from './style.module.css';
import AppContext from 'components/AppContext';
class FormActions extends React.PureComponent {
static contextType = AppContext
downloadLink({ url, filename, type, label }) {
return <li>
<a href={ url } download={ filename } type={ type }>
@ -23,7 +20,7 @@ class FormActions extends React.PureComponent {
permalinkUrl,
svgLink,
pngLink
} = this.context;
} = this.props;
return <ul className={ style.actions }>
{ pngLink && this.downloadLink(pngLink) }
@ -35,4 +32,10 @@ class FormActions extends React.PureComponent {
}
}
FormActions.propTypes = {
permalinkUrl: PropTypes.string,
svgLink: PropTypes.object,
pngLink: PropTypes.object
};
export default FormActions;

View File

@ -1,14 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import PlaceholderIcon from 'react-feather/dist/icons/file-text';
import style from './style.module.css';
import AppContext from 'components/AppContext';
class Render extends React.PureComponent {
static contextType = AppContext
svgContainer = React.createRef()
provideSVGData() {
@ -17,7 +14,7 @@ class Render extends React.PureComponent {
}
const svg = this.svgContainer.current.querySelector('svg');
this.context.setSvgMarkup({
this.props.onRender({
svg: svg.outerHTML,
width: svg.getAttribute('width'),
height: svg.getAttribute('height')
@ -33,7 +30,7 @@ class Render extends React.PureComponent {
}
render() {
const { syntax, expr } = this.context;
const { syntax, expr } = this.props;
console.log('Render:', syntax, expr); // eslint-disable-line no-console
@ -44,4 +41,10 @@ class Render extends React.PureComponent {
}
}
Render.propTypes = {
syntax: PropTypes.string,
expr: PropTypes.string,
onRender: PropTypes.func.isRequired
};
export default Render;

View File

@ -22,6 +22,40 @@ exports[`Index Page rendering 1`] = `
</p>
</Message>
</noscript>
<App />
<App
expr=""
permalinkUrl={null}
syntax="js"
/>
</Fragment>
`;
exports[`Index Page rendering with an expression on the URL 1`] = `
<Fragment>
<Metadata />
<noscript>
<Message
heading="JavaScript Required"
type="error"
>
<p>
You need JavaScript to use Regexper.
</p>
<p>
If you have concerns regarding the use of tracking code on Regexper, please see the
<mockConstructor
to="/privacy"
>
Privacy Policy
</mockConstructor>
.
</p>
</Message>
</noscript>
<App
expr="testing"
permalinkUrl="http://example.com"
syntax="test"
/>
</Fragment>
`;

View File

@ -1,11 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'gatsby';
import URLSearchParams from '@ungap/url-search-params';
import Metadata from 'components/Metadata';
import Message from 'components/Message';
import App from 'components/App';
export const IndexPage = () => <>
const readURLHash = location => {
const query = location.hash.slice(1);
const params = new URLSearchParams(query);
const permalinkUrl = query ? location.href : null;
if (params.get('syntax')) {
return {
syntax: params.get('syntax'),
expr: params.get('expr'),
permalinkUrl
};
} else {
// Assuming old-style URL
return {
syntax: 'js',
expr: query,
permalinkUrl
};
}
};
export const IndexPage = ({ location }) => <>
<Metadata/>
<noscript>
<Message type="error" heading="JavaScript Required">
@ -14,7 +37,11 @@ export const IndexPage = () => <>
please see the <Link to="/privacy">Privacy Policy</Link>.</p>
</Message>
</noscript>
<App />
<App { ...readURLHash(location) } />
</>;
IndexPage.propTypes = {
location: PropTypes.object
};
export default IndexPage;

View File

@ -6,7 +6,17 @@ import { IndexPage } from 'pages/index';
describe('Index Page', () => {
test('rendering', () => {
const component = shallow(
<IndexPage />
<IndexPage location={{ hash: '' }} />
);
expect(component).toMatchSnapshot();
});
test('rendering with an expression on the URL', () => {
const component = shallow(
<IndexPage location={{
hash: '#syntax=test&expr=testing',
href: 'http://example.com'
}} />
);
expect(component).toMatchSnapshot();
});