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/jest.config.js b/jest.config.js index 4ebdc68c..5e844c31 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,9 +2,12 @@ module.exports = { preset: 'ts-jest/presets/js-with-babel', testEnvironment: 'jsdom', collectCoverage: true, + setupFilesAfterEnv: [ + '@testing-library/react/cleanup-after-each' + ], // For some reason Jest is not measuring coverage without the below option. // Unfortunately, despite `!(.test)`, it still measures coverage of test files as well: - forceCoverageMatch: ['**/*!(.test).ts'], + forceCoverageMatch: ['**/*!(.test).tsx?'], // Since we're only measuring coverage for TypeScript (i.e. added with test infrastructure in place), // we can be fairly strict. However, if you feel that something is not fit for coverage, // mention why in a comment and mark it as ignored: diff --git a/markdown/__snapshots__/view.test.tsx.snap b/markdown/__snapshots__/view.test.tsx.snap new file mode 100644 index 00000000..ed19dff1 --- /dev/null +++ b/markdown/__snapshots__/view.test.tsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Edit mode should properly render the edit form 1`] = ` +
+
+ + + , +
+
+`; + +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/container.tsx b/markdown/container.tsx new file mode 100644 index 00000000..da63ed7b --- /dev/null +++ b/markdown/container.tsx @@ -0,0 +1,32 @@ +import * as React from 'react' +import { loadMarkdown, saveMarkdown } from './service' +import { View } from './view' +import { ContainerProps } from '../types' + +export const Container: React.FC = (props) => { + const [markdown, setMarkdown] = React.useState() + + React.useEffect(() => { + loadMarkdown(props.store, props.subject.uri) + .then((markdown) => setMarkdown(markdown)) + .catch(() => setMarkdown(null)) + }) + + if (typeof markdown === 'undefined') { + return
Loading…
+ } + if (markdown === null) { + return
Error loading markdown :(
+ } + + const saveHandler = (newMarkdown: string) => saveMarkdown(props.store, props.subject.uri, newMarkdown) + + return ( +
+ +
+ ) +} diff --git a/markdown/index.tsx b/markdown/index.tsx new file mode 100644 index 00000000..03772fd4 --- /dev/null +++ b/markdown/index.tsx @@ -0,0 +1,41 @@ +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 } from './service' +import { Container } from './container' + +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') + ReactDOM.render(, 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.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/markdown/view.tsx b/markdown/view.tsx new file mode 100644 index 00000000..bab4849a --- /dev/null +++ b/markdown/view.tsx @@ -0,0 +1,41 @@ +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<'saving' | 'rendering' | 'editing'>('rendering') + const [rawText, setRawText] = React.useState(props.markdown) + + function storeMarkdown () { + setPhase('saving') + props.onSave(rawText).then(() => { + setPhase('rendering') + }) + } + + if (phase === 'saving') { + return Loading… + } + + if (phase === 'editing') { + return ( +
{ e.preventDefault(); storeMarkdown() }}> +