From 9248c3873c700944e2795448cef821d1749fa256 Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 12 Jun 2019 09:09:26 +0200 Subject: [PATCH 1/3] Use React in a new Markdown pane --- .babelrc | 1 + .eslintrc | 3 +- index.js | 1 + markdown/index.tsx | 49 ++++ markdown/service.ts | 13 ++ markdown/view.tsx | 45 ++++ package-lock.json | 541 +++++++++++++++++++++++++++++++++++++++++++- package.json | 7 + tsconfig.json | 2 +- types.ts | 16 +- webpack.config.js | 4 +- 11 files changed, 653 insertions(+), 29 deletions(-) create mode 100644 markdown/index.tsx create mode 100644 markdown/service.ts create mode 100644 markdown/view.tsx diff --git a/.babelrc b/.babelrc index eb6e320a..630c5d11 100644 --- a/.babelrc +++ b/.babelrc @@ -1,6 +1,7 @@ { "presets": [ "@babel/preset-env", + "@babel/preset-react", "@babel/preset-typescript" ], } diff --git a/.eslintrc b/.eslintrc index f51aa813..89511dbf 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,7 +14,8 @@ "project": "./tsconfig.json" }, "plugins": [ - "@typescript-eslint" + "@typescript-eslint", + "react" ], "rules": { "no-unused-vars": ["warn", { diff --git a/index.js b/index.js index e609c51b..bd54dfbc 100644 --- a/index.js +++ b/index.js @@ -47,6 +47,7 @@ if (typeof window !== 'undefined') { let register = panes.register +register(require('./markdown/index.tsx').Pane) register(require('issue-pane')) register(require('contacts-pane')) diff --git a/markdown/index.tsx b/markdown/index.tsx new file mode 100644 index 00000000..40f20a63 --- /dev/null +++ b/markdown/index.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import { PaneDefinition, NewPaneOptions } from '../types' +import $rdf from 'rdflib' +import solidUi from 'solid-ui' +import { saveMarkdown, loadMarkdown } from './service' +import { View } from './view' + +const { icons, store } = solidUi + +export const Pane: PaneDefinition = { + icon: `${icons.iconBase}noun_79217.svg`, + name: 'MarkdownPane', + label: (subject) => subject.uri.endsWith('.md') ? 'Handle markdown file' : null, + mintNew: function (options) { + const newInstance = createFileName(options) + return saveMarkdown(store, newInstance.uri, '# This is your markdown file\n\nHere be stuff!') + .then(() => ({ + ...options, + newInstance + })) + .catch((err: any) => { + console.error('Error creating new instance of markdown file', err) + return options + }) + }, + render: (subject) => { + const container = document.createElement('div') + + loadMarkdown(store, subject.uri).then((markdown) => { + const view = ( + saveMarkdown(store, subject.uri, newMarkdown)} + /> + ) + ReactDOM.render(view, container) + }) + return container + } +} + +function createFileName (options: NewPaneOptions): $rdf.NamedNode { + let uri = options.newBase + if (uri.endsWith('/')) { + uri = uri.slice(0, -1) + '.md' + } + return $rdf.sym(uri) +} diff --git a/markdown/service.ts b/markdown/service.ts new file mode 100644 index 00000000..6efb3be9 --- /dev/null +++ b/markdown/service.ts @@ -0,0 +1,13 @@ +import { IndexedFormula } from 'rdflib' + +export function loadMarkdown (store: IndexedFormula, uri: string): Promise { + return (store as any).fetcher.webOperation('GET', uri) + .then((response: any) => response.responseText) +} + +export function saveMarkdown (store: IndexedFormula, uri: string, data: string): Promise { + return (store as any).fetcher.webOperation('PUT', uri, { + data, + contentType: 'text/markdown; charset=UTF-8' + }) +} diff --git a/markdown/view.tsx b/markdown/view.tsx new file mode 100644 index 00000000..f9810876 --- /dev/null +++ b/markdown/view.tsx @@ -0,0 +1,45 @@ +import * as React from 'react' +import Markdown from 'react-markdown' + +interface Props { + markdown: string; + onSave: (newMarkdown: string) => Promise; +} + +export const View: React.FC = (props) => { + const [phase, setPhase] = React.useState<'loading' | 'rendering' | 'editing'>('rendering') + const [rawText, setRawText] = React.useState(props.markdown) + + function storeMarkdown () { + setPhase('loading') + props.onSave(rawText).then(() => { + setPhase('rendering') + }) + } + + if (phase === 'loading') { + return
Loading…
+ } + + if (phase === 'editing') { + return ( +
+
{ e.preventDefault(); storeMarkdown() }}> + + + , +
+ +`; + +exports[`should properly render markdown 1`] = ` +
+
+

+ Some + + awesome + + markdown +

+ +
+
+`; diff --git a/markdown/actErrorWorkaround.ts b/markdown/actErrorWorkaround.ts new file mode 100644 index 00000000..cf3a88b3 --- /dev/null +++ b/markdown/actErrorWorkaround.ts @@ -0,0 +1,26 @@ +/* eslint-env jest */ + +/* istanbul ignore next [This is a test helper, so it doesn't need to be tested itself] */ +/** + * This is a workaround for a bug that will be fixed in react-dom@16.9 + * + * The bug results in a warning being thrown about calls not being wrapped in `act()` + * when a component calls `setState` twice. + * More info about the issue: https://github.com/testing-library/react-testing-library/issues/281#issuecomment-480349256 + * The PR that will fix it: https://github.com/facebook/react/pull/14853 + */ +export function workaroundActError () { + const originalError = console.error + beforeAll(() => { + console.error = (...args) => { + if (/Warning.*not wrapped in act/.test(args[0])) { + return + } + originalError.call(console, ...args) + } + }) + + afterAll(() => { + console.error = originalError + }) +} diff --git a/markdown/view.test.tsx b/markdown/view.test.tsx new file mode 100644 index 00000000..d30ae4e6 --- /dev/null +++ b/markdown/view.test.tsx @@ -0,0 +1,44 @@ +/* eslint-env jest */ +import * as React from 'react' +import { + render, + fireEvent +} from '@testing-library/react' +import { View } from './view' +import { workaroundActError } from './actErrorWorkaround' + +workaroundActError() + +it('should properly render markdown', () => { + const { container } = render() + + expect(container).toMatchSnapshot() +}) + +describe('Edit mode', () => { + it('should properly render the edit form', () => { + const { container, getByRole } = render() + + const editButton = getByRole('button') + editButton.click() + + expect(container).toMatchSnapshot() + }) + + it('should call the onSave handler after saving the new content', () => { + const mockHandler = jest.fn().mockReturnValue(Promise.resolve()) + const { getByRole, getByDisplayValue } = render() + + const editButton = getByRole('button') + editButton.click() + + const textarea = getByDisplayValue('Arbitrary markdown') + fireEvent.change(textarea, { target: { value: 'Some _other_ markdown' } }) + + const renderButton = getByRole('button') + renderButton.click() + + expect(mockHandler.mock.calls.length).toBe(1) + expect(mockHandler.mock.calls[0][0]).toBe('Some _other_ markdown') + }) +}) diff --git a/package-lock.json b/package-lock.json index f0bff555..5ebfd5be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2489,6 +2489,12 @@ "@types/yargs": "^12.0.9" } }, + "@sheerun/mutationobserver-shim": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz", + "integrity": "sha512-vTCdPp/T/Q3oSqwHmZ5Kpa9oI7iLtGl3RQaA/NyLHikvcrPxACkkKVr/XzkSPJWXHRhKGzVvb0urJsbMlRxi1Q==", + "dev": true + }, "@solid/better-simple-slideshow": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@solid/better-simple-slideshow/-/better-simple-slideshow-0.1.0.tgz", @@ -2571,6 +2577,29 @@ } } }, + "@testing-library/dom": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-5.2.0.tgz", + "integrity": "sha512-nFaZes/bzDfMqwZpQXdiPyj3WXU16FYf5k5NCFu/qJM4JdRJLHEtSRYtrETmk7nCf+qLVoHCqRduGi/4KE83Gw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.5", + "@sheerun/mutationobserver-shim": "^0.3.2", + "aria-query": "3.0.0", + "pretty-format": "^24.8.0", + "wait-for-expect": "^1.2.0" + } + }, + "@testing-library/react": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-8.0.1.tgz", + "integrity": "sha512-N/1pJfhEnNYkGyxuw4xbp03evaS0z/CT8o0QgTfJqGlukAcU15xf9uU1w03NHKZJcU69nOCBAoAkXHtHzYwMbg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.5", + "@testing-library/dom": "^5.0.0" + } + }, "@trust/jose": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/@trust/jose/-/jose-0.1.7.tgz", @@ -3124,6 +3153,16 @@ "sprintf-js": "~1.0.2" } }, + "aria-query": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-3.0.0.tgz", + "integrity": "sha1-ZbP8wcoRVajJrmTW7uKX8V1RM8w=", + "dev": true, + "requires": { + "ast-types-flow": "0.0.7", + "commander": "^2.11.0" + } + }, "arr-diff": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", @@ -3220,6 +3259,12 @@ "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", "dev": true }, + "ast-types-flow": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", + "integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0=", + "dev": true + }, "astral-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", @@ -12559,6 +12604,12 @@ "browser-process-hrtime": "^0.1.2" } }, + "wait-for-expect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/wait-for-expect/-/wait-for-expect-1.2.0.tgz", + "integrity": "sha512-EJhKpA+5UHixduMBEGhTFuLuVgQBKWxkFbefOdj2bbk2/OpA5Opsc4aUTGmF+qJ+v3kTGxDRNYwKaT4j6g5n8Q==", + "dev": true + }, "walker": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz", diff --git a/package.json b/package.json index 1c57eda8..34bd6a2d 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@babel/preset-env": "^7.4.4", "@babel/preset-react": "^7.0.0", "@babel/preset-typescript": "^7.3.3", + "@testing-library/react": "^8.0.1", "@types/jest": "^24.0.12", "@types/rdflib": "^0.20.0", "@types/react": "^16.8.19",