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/dashboard/dashboardPane.tsx b/dashboard/dashboardPane.tsx new file mode 100644 index 00000000..6b1b5e40 --- /dev/null +++ b/dashboard/dashboardPane.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import UI from 'solid-ui' +import panes from 'pane-registry' +import { Wrapper } from './wrapper' +import { PaneDefinition } from '../types' + +const HomePane: PaneDefinition = { + icon: UI.icons.iconBase + 'noun_547570.svg', // noun_25830 + + name: 'dashboard', + + label: function () { + return 'Dashboard' + }, + + render: function (subject, dom) { + const container = document.createElement('div') + const loadResource = (resourcePath: string) => { + panes.getOutliner(dom).GotoSubject(resourcePath, true, undefined, true) + } + UI.authn.solidAuthClient.currentSession().then((session: any) => { + ReactDOM.render( + , + container + ) + }) + + return container + } +} // pane object + +// ends +export default HomePane diff --git a/dashboard/datasister-dashboard/Dashboard.tsx b/dashboard/datasister-dashboard/Dashboard.tsx new file mode 100644 index 00000000..d1b1e2e8 --- /dev/null +++ b/dashboard/datasister-dashboard/Dashboard.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { ProfileWidget } from './widgets/Profile' +import { BookmarksWidget } from './widgets/Bookmarks' +import { FolderWidget } from './widgets/Folder' +import { AppsWidget } from './widgets/Apps' + +export const Dashboard: React.FC<{ +}> = (props) => { + return ( + <> +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/dashboard/datasister-dashboard/components/ResourceLink.tsx b/dashboard/datasister-dashboard/components/ResourceLink.tsx new file mode 100644 index 00000000..189c7444 --- /dev/null +++ b/dashboard/datasister-dashboard/components/ResourceLink.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { NamedNode } from 'rdflib' +import UI from 'solid-ui' +import { DataBrowserContext } from '../context' + +interface OwnProps { + resource: NamedNode; +}; + +type Props = Omit, keyof OwnProps> & OwnProps; + +export const ResourceLink: React.FC = (props) => { + const { store, loadResource, podOrigin } = React.useContext(DataBrowserContext) + const clickHandler = (event: React.MouseEvent) => { + if (props.resource.uri.substring(0, podOrigin.length) === podOrigin) { + event.preventDefault() + loadResource(props.resource.uri) + } + } + + const children = (props.children) + ? props.children + : UI.label(props.resource, store, podOrigin) + + let title = props.title + if (!title) { + title = (typeof children === 'string') + ? `View ${children}` + : `View ${UI.label(props.resource, store, podOrigin)}` + } + + const anchorProps = { + ...props, + title: title, + resource: undefined + } + + return ( + + {children} + + ) +} diff --git a/dashboard/datasister-dashboard/context.tsx b/dashboard/datasister-dashboard/context.tsx new file mode 100644 index 00000000..782c2234 --- /dev/null +++ b/dashboard/datasister-dashboard/context.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import $rdf, { IndexedFormula, Fetcher, UpdateManager, NamedNode } from 'rdflib' + +export interface DataBrowserContextData { + store: IndexedFormula; + fetcher: Fetcher; + updater: UpdateManager; + podOrigin: string; + webId: string; + loadResource: (resourcePath: string) => void; +}; + +const defaultContext: DataBrowserContextData = { + podOrigin: document.location.origin, + store: $rdf.graph(), + fetcher: new Fetcher($rdf.graph(), undefined), + updater: new UpdateManager($rdf.graph()), + webId: 'http://example.com', + loadResource: () => undefined +} + +/** + * The context allows the data browser to easily access global values + * everywhere in the application. + * Individual Panes, however, should get these values as properties, + * to avoid a hard dependency on the data browser. + * This will allow them to be used as e.g. individual apps or in browser extensions. + */ +export const DataBrowserContext = React.createContext(defaultContext) diff --git a/dashboard/datasister-dashboard/hooks/useWebId.ts b/dashboard/datasister-dashboard/hooks/useWebId.ts new file mode 100644 index 00000000..c5c8afc2 --- /dev/null +++ b/dashboard/datasister-dashboard/hooks/useWebId.ts @@ -0,0 +1,11 @@ +import React from 'react' +import { DataBrowserContext } from '../context' + +/** + * API-compatible with @solid/react's `useWebId`, but fetches it from the context object + */ +export function useWebId () { + const { webId } = React.useContext(DataBrowserContext) + + return webId +} diff --git a/dashboard/datasister-dashboard/widgets/Apps.tsx b/dashboard/datasister-dashboard/widgets/Apps.tsx new file mode 100644 index 00000000..cadc2a3f --- /dev/null +++ b/dashboard/datasister-dashboard/widgets/Apps.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { DataBrowserContext } from '../context' + +export const AppsWidget: React.FC = () => { + const { podOrigin } = React.useContext(DataBrowserContext) + + const appLink = (podOrigin) + ? `https://pixolid.netlify.com/?idp=${podOrigin}` + : 'https://pixolid.netlify.com/' + + return ( +
+
+

Try this app

+

+ + Pixolid + +

+
+
+ ) +} diff --git a/dashboard/datasister-dashboard/widgets/Apps/pixolid.svg b/dashboard/datasister-dashboard/widgets/Apps/pixolid.svg new file mode 100644 index 00000000..c6749a9a --- /dev/null +++ b/dashboard/datasister-dashboard/widgets/Apps/pixolid.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dashboard/datasister-dashboard/widgets/Bookmarks.tsx b/dashboard/datasister-dashboard/widgets/Bookmarks.tsx new file mode 100644 index 00000000..6b149cbb --- /dev/null +++ b/dashboard/datasister-dashboard/widgets/Bookmarks.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import $rdf from 'rdflib' +import namespaces from 'solid-namespace' +import { DataBrowserContext } from '../context' +import { useWebId } from '../hooks/useWebId' + +const ns = namespaces($rdf) + +export const BookmarksWidget: React.FC = () => { + const { store, fetcher, podOrigin } = React.useContext(DataBrowserContext) + + const bookmarks = useBookmarks(store, fetcher) + + if (!Array.isArray(bookmarks) || bookmarks.length === 0) { + return null + } + + return ( +
+
+

Latest bookmarks

+
+ +
+ All bookmarks +
+
+ ) +} + +function useBookmarks (store: $rdf.IndexedFormula, fetcher: $rdf.Fetcher) { + const webId = useWebId() + const [bookmarks, setBookmarks] = React.useState>() + + React.useEffect(() => { + if (!webId) { + return + } + getBookmarks(store, fetcher, webId) + .then(setBookmarks) + .catch((e) => console.log('Error fetching bookmarks:', e)) + }, [store, fetcher, webId]) + + return bookmarks +} + +async function getBookmarks (store: $rdf.IndexedFormula, fetcher: $rdf.Fetcher, webId: string) { + const profile = $rdf.sym(webId) + const [ publicTypeIndexStatement ] = store.statementsMatching(profile, ns.solid('publicTypeIndex'), null, profile.doc(), true) + const publicTypeIndex = publicTypeIndexStatement.object + await fetcher.load(publicTypeIndex as any as $rdf.NamedNode) + const bookmarkClass = new $rdf.NamedNode('http://www.w3.org/2002/01/bookmark#Bookmark') + const [ bookmarkRegistryStatement ] = store.statementsMatching(null, ns.solid('forClass'), bookmarkClass, publicTypeIndex, true) + const bookmarkRegistry = bookmarkRegistryStatement.subject + const [ bookmarkRegistryInstanceStatement ] = store.statementsMatching(bookmarkRegistry, ns.solid('instance'), null, publicTypeIndex, true) + const bookmarkRegistryInstance = bookmarkRegistryInstanceStatement.object + await fetcher.load(bookmarkRegistryInstance as any as $rdf.NamedNode) + const bookmarkStatements = store.statementsMatching(null, ns.rdf('type'), bookmarkClass, bookmarkRegistryInstance) + return bookmarkStatements.map((statement) => { + const recalls = new $rdf.NamedNode('http://www.w3.org/2002/01/bookmark#recalls') + const bookmarkNode = statement.subject + const [ titleStatement ] = store.statementsMatching(bookmarkNode, ns.dct('title'), null, bookmarkRegistryInstance) + const [ urlStatement ] = store.statementsMatching(bookmarkNode, recalls, null, bookmarkRegistryInstance) + return { + title: titleStatement.object.value, + url: urlStatement.object.value + } + }) +} diff --git a/dashboard/datasister-dashboard/widgets/Folder.tsx b/dashboard/datasister-dashboard/widgets/Folder.tsx new file mode 100644 index 00000000..b48e87a6 --- /dev/null +++ b/dashboard/datasister-dashboard/widgets/Folder.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import $rdf from 'rdflib' +import { ResourceLink } from '../components/ResourceLink' +import { DataBrowserContext } from '../context' + +export const FolderWidget: React.FC = () => { + const { podOrigin } = React.useContext(DataBrowserContext) + + return ( +
+
+

Raw data

+

+ Public data + Private data +

+
+
+ ) +} diff --git a/dashboard/datasister-dashboard/widgets/Profile.tsx b/dashboard/datasister-dashboard/widgets/Profile.tsx new file mode 100644 index 00000000..c8d80c7d --- /dev/null +++ b/dashboard/datasister-dashboard/widgets/Profile.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export const ProfileWidget: React.FC = () => { + return ( +
+
+

My profile

+

No name provided…

+
+
+ ); +} diff --git a/dashboard/wrapper.tsx b/dashboard/wrapper.tsx new file mode 100644 index 00000000..98b5a422 --- /dev/null +++ b/dashboard/wrapper.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { DataBrowserContextData, DataBrowserContext } from './datasister-dashboard/context'; +import { IndexedFormula, Fetcher, UpdateManager } from 'rdflib'; +import { Dashboard } from './datasister-dashboard/Dashboard'; + +interface Props { + store: IndexedFormula, + fetcher: Fetcher, + updater: UpdateManager, + webId: string, + loadResource: (resourcePath: string) => void +} +export const Wrapper: React.FC = (props) => { + const dataBrowserContext: DataBrowserContextData = { + store: props.store, + fetcher: props.fetcher, + updater: props.updater, + webId: props.webId, + podOrigin: document.location.origin, + loadResource: props.loadResource + } + + return ( + + + + ) +} diff --git a/index.js b/index.js index e609c51b..697853d1 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')) @@ -127,10 +128,11 @@ register(require('./sharing/sharingPane.js')) // The internals pane is always (almost?) the last as it is the least user-friendly register(require('./internalPane.js')) -// The home pane is a 2016 experiment. Always there. register(require('./profile/profilePane').default) // edit your public profile register(require('./trustedApplications/trustedApplicationsPane').default) // manage your trusted applications +// The home pane is a 2016 experiment. Always there. register(require('./home/homePane').default) +register(require('./dashboard/dashboardPane').default) // ENDS 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() }}> +