From 15182684cb62bf15f04d94e08252633d10864e03 Mon Sep 17 00:00:00 2001 From: Parsa Ghadimi Date: Mon, 14 May 2018 21:46:28 +0430 Subject: [PATCH 01/10] components: ErrorPage --- src/components/error.tsx | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/components/error.tsx diff --git a/src/components/error.tsx b/src/components/error.tsx new file mode 100644 index 00000000..2f11a224 --- /dev/null +++ b/src/components/error.tsx @@ -0,0 +1,32 @@ +/*! + Copyright 2018 Propel http://propel.site/. All rights reserved. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { h } from "preact"; + +export interface ErrorPageProps { + header?: string; + message: string; +} + +export function ErrorPage(props: ErrorPageProps) { + return ( +
+
+

{ props.header || "Error" }

+

{ props.message }

+
+
+ ); +} From c21bd2901889c5f03695d27c0ab3c8c28a19741e Mon Sep 17 00:00:00 2001 From: Parsa Ghadimi Date: Fri, 18 May 2018 11:14:55 +0430 Subject: [PATCH 02/10] Implement router --- package.json | 1 + src/router.tsx | 129 +++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + yarn.lock | 4 ++ 4 files changed, 135 insertions(+) create mode 100644 src/router.tsx diff --git a/package.json b/package.json index a8da7a72..70759703 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "node-sass": "^4.9.0", "parcel-bundler": "^1.8.1", "parcel-plugin-markdown": "^0.3.1", + "path-to-regexp": "^2.2.1", "preact": "^8.2.7", "prettier": "^1.12.1", "puppeteer": "^0.13.0", diff --git a/src/router.tsx b/src/router.tsx new file mode 100644 index 00000000..62eef178 --- /dev/null +++ b/src/router.tsx @@ -0,0 +1,129 @@ +/*! + Copyright 2018 Propel http://propel.site/. All rights reserved. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import pathToRegexp from "path-to-regexp"; +import { cloneElement, Component, VNode } from "preact"; + +let id = 0; +const listeners = new Map void>(); +const regexCache = new Map(); +const regexKeysCache = new Map(); + +export interface MatchedResult { + [key: string]: string; +} + +export function match(pattern: string): false | MatchedResult { + const path = window.location.pathname; + let regex = regexCache.get(pattern); + let keys = regexKeysCache.get(pattern); + if (!regex) { + const mKeys = []; + regex = pathToRegexp(pattern, mKeys); + keys = mKeys.map(x => x.name); + regexCache.set(pattern, regex); + regexKeysCache.set(pattern, keys); + } + const re = regex.exec(path); + if (!re) return false; + const data = {}; + for (const i in keys) { + if (!keys[i]) continue; + const key = keys[i]; + data[key] = re[1 + Number(i)]; + } + return data; +} + +function onPopState() { + const mapValues = listeners.values(); + for (const cb of mapValues) { + cb(); + } +} + +window.onpopstate = onPopState; + +export function pushState(url) { + window.history.pushState({}, document.title, url); + onPopState(); +} + +export function back() { + window.history.back(); +} + +export interface RouterChildProps { + path?: string; +} + +export type RouterChild = VNode; + +export interface RouterProps { + children?: RouterChild[]; +} + +export interface RouterState { + active: number | string; + props: MatchedResult; +} + +export class Router extends Component { + state = { + active: null, + props: null + }; + id: number; + + onLocationChange() { + const children: RouterChild[] = this.props.children; + for (const i in children) { + if (!children[i]) continue; + const child = children[i]; + const attributes = child.attributes; + if (!attributes || !attributes.path) { + this.setState({ active: i, props: null }); + return; + } + const props = match(attributes.path); + if (!props) continue; + this.setState({ active: i, props }); + return; + } + this.setState({ active: null, props: null }); + } + + componentWillMount() { + this.id = ++id; + listeners.set(id, this.onLocationChange.bind(this)); + this.onLocationChange(); + } + + componentWillUnmount() { + listeners.delete(this.id); + } + + componentWillReceiveProps() { + this.onLocationChange(); + } + + render() { + const { active, props } = this.state; + if (active === null) return null; + const activeEl = this.props.children[active]; + const newProps = { ...activeEl.attributes, matches: props }; + return cloneElement(activeEl, newProps); + } +} diff --git a/tsconfig.json b/tsconfig.json index 552a2671..264c6fb7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "preserveConstEnums": true, "pretty": true, "sourceMap": true, + "allowSyntheticDefaultImports": true, "target": "es2017" }, "exclude": ["build", "deps", "node_modules", "src/testdata/"] diff --git a/yarn.lock b/yarn.lock index 35950fec..049a6135 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3907,6 +3907,10 @@ path-parse@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1" +path-to-regexp@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-2.2.1.tgz#90b617025a16381a879bc82a38d4e8bdeb2bcf45" + path-type@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" From 7aa2f6e75f1fad6e832d43e077a212ab2dc10b55 Mon Sep 17 00:00:00 2001 From: Parsa Ghadimi Date: Fri, 18 May 2018 11:17:04 +0430 Subject: [PATCH 03/10] rm notebook_root.tsx and other unused codes --- src/db.ts | 2 - src/notebook_root.tsx | 243 ------------------------------------------ src/pages.tsx | 75 ------------- 3 files changed, 320 deletions(-) delete mode 100644 src/notebook_root.tsx diff --git a/src/db.ts b/src/db.ts index 851e4785..58df7aa5 100644 --- a/src/db.ts +++ b/src/db.ts @@ -102,7 +102,6 @@ class DatabaseFB implements Database { title: "", updated: firebase.firestore.FieldValue.serverTimestamp() }; - console.log({ newDoc }); const docRef = await nbCollection.add(newDoc); return docRef.id; } @@ -123,7 +122,6 @@ class DatabaseFB implements Database { title: "", updated: firebase.firestore.FieldValue.serverTimestamp() }; - console.log({ newDoc }); const docRef = await nbCollection.add(newDoc); return docRef.id; } diff --git a/src/notebook_root.tsx b/src/notebook_root.tsx deleted file mode 100644 index fba9c6c1..00000000 --- a/src/notebook_root.tsx +++ /dev/null @@ -1,243 +0,0 @@ -/*! - Copyright 2018 Propel http://propel.site/. All rights reserved. - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -// Propel Notebooks. -// Note that this is rendered and executed server-side using JSDOM and then is -// re-rendered client-side. The Propel code in the cells are executed -// server-side so the results can be displayed even if javascript is disabled. - -import { Component, h } from "preact"; -import { normalizeCode } from "./components/common"; -import { GlobalHeader } from "./components/header"; -import { Loading } from "./components/loading"; -import { UserMenu } from "./components/menu"; -import { Notebook } from "./components/notebook"; -import { Profile } from "./components/profile"; -import { Recent } from "./components/recent"; -import * as db from "./db"; -import * as types from "./types"; - -// An anonymous notebook doc for when users aren't logged in -const anonDoc = { - anonymous: true, - cells: [], - created: new Date(), - owner: { - displayName: "Anonymous", - photoURL: require("./img/anon_profile.png"), - uid: "" - }, - title: "Anonymous Notebook", - updated: new Date() -}; - -export interface FixedProps { - code: string; -} - -// FixedCell is for non-executing notebook-lookalikes. Usually will be used for -// non-javascript code samples. -// TODO share more code with Cell. -export class FixedCell extends Component { - render() { - // Render as a pre in case people don't have javascript turned on. - return ( -
-
-
{normalizeCode(this.props.code)}
-
-
- ); - } -} - -export interface NotebookRootProps { - userInfo?: types.UserInfo; // The current user who is logged in. - // If nbId is specified, it will be queried, and set in doc. - nbId?: string; - // If profileId is specified, it will be queried. - profileUid?: string; - // If neither nbId nor profileUid is specified, NotebookRoot will - // use the current URL's query string to search fo nbId and profile. - // If those are not found, NotebookRoot will query the most recent. - onReady: () => void; -} - -export interface NotebookRootState { - // Same as in props, but after checking window.location. - nbId?: string; - profileUid?: string; - - // If set a Notebook for this doc will be displayed. - doc?: types.NotebookDoc; - // If set the most-recent page will be displayed. - mostRecent?: types.NbInfo[]; - // If set the profile page will be displayed. - profileLatest?: types.NbInfo[]; - - errorMsg?: string; -} - -export class NotebookRoot extends Component< - NotebookRootProps, - NotebookRootState -> { - notebookRef?: Notebook; // Hook for testing. - isCloningInProgress: boolean; - - constructor(props) { - super(props); - let nbId; - if (this.props.nbId) { - nbId = this.props.nbId; - } else { - const matches = window.location.search.match(/nbId=(\w+)/); - nbId = matches ? matches[1] : null; - } - let profileUid; - if (this.props.profileUid) { - profileUid = this.props.profileUid; - } else { - const matches = window.location.search.match(/profile=(\w+)/); - profileUid = matches ? matches[1] : null; - } - this.state = { nbId, profileUid }; - } - - async componentWillMount() { - // Here is where we query firebase for all sorts of messages. - const { nbId, profileUid } = this.state; - try { - if (nbId) { - // nbId specified. Query the the notebook. - const doc = await (nbId === "anonymous" - ? Promise.resolve(anonDoc) - : db.active.getDoc(nbId)); - this.setState({ doc }); - } else if (profileUid) { - // profileUid specified. Query the profile. - const profileLatest = await db.active.queryProfile(profileUid, 100); - this.setState({ profileLatest }); - } else { - // Neither specified. Show the most-recent. - // TODO potentially these two queries can be combined into one. - const mostRecent = await db.active.queryLatest(); - this.setState({ mostRecent }); - } - } catch (e) { - this.setState({ errorMsg: e.message }); - } - } - - async componentDidUpdate() { - // Call the onReady callback for testing. - if ( - this.state.errorMsg || - this.state.mostRecent || - this.state.profileLatest || - this.state.doc - ) { - if (this.props.onReady) this.props.onReady(); - } - } - - private async onNewNotebook() { - const nbId = await db.active.create(); - window.location.href = nbUrl(nbId); - } - - private async onOpenNotebook(nbId: string) { - window.location.href = nbUrl(nbId); - } - - private async handleNotebookSave(doc: types.NotebookDoc) { - this.setState({ doc }); - if (doc.anonymous) return; - if (!this.props.userInfo) return; - if (this.props.userInfo.uid !== doc.owner.uid) return; - try { - await db.active.updateDoc(this.state.nbId, doc); - } catch (e) { - // TODO - console.log(e); - } - } - - async handleNotebookClone() { - if (this.isCloningInProgress) return; - this.isCloningInProgress = true; - const cloneId = await db.active.clone(this.state.doc); - // Redirect to new notebook. - window.location.href = nbUrl(cloneId); - } - - render() { - let body; - if (this.state.errorMsg) { - body = ( -
-
-

Error

-

{this.state.errorMsg}

-
-
- ); - } else if (this.state.profileLatest) { - body = ( - - ); - } else if (this.state.doc) { - body = ( - (this.notebookRef = ref)} - userInfo={this.props.userInfo} - /> - ); - } else if (this.state.mostRecent) { - body = ( - - ); - } else { - body = ; - } - - return ( -
- - - - {body} -
- ); - } -} - -function nbUrl(nbId: string): string { - const u = window.location.origin + "/notebook?nbId=" + nbId; - return u; -} diff --git a/src/pages.tsx b/src/pages.tsx index 7f9b9b6b..1c3283f3 100644 --- a/src/pages.tsx +++ b/src/pages.tsx @@ -15,22 +15,6 @@ // tslint:disable:variable-name // This is the propelml.org website. It is used both server-side and // client-side for generating HTML. -import { Component, h, render } from "preact"; -import { Home } from "./components/home"; -import * as db from "./db"; -import * as nb from "./notebook_root"; -import * as types from "./types"; - -export interface Page { - title: string; - path: string; - root: any; - route: RegExp; -} - -export function renderPage(p: Page): void { - render(h(p.root, null), document.body, document.body.children[0]); -} export let firebaseUrls = [ "https://www.gstatic.com/firebasejs/4.9.0/firebase.js", @@ -67,62 +51,3 @@ export function getHTML(title, markup) { `; } - -export interface RouterState { - userInfo?: types.UserInfo; - loadingAuth: boolean; -} - -// The root of all pages of the propel website. -// Handles auth. -export class Router extends Component { - constructor(props) { - super(props); - this.state = { - loadingAuth: true, - userInfo: null - }; - } - - unsubscribe: db.UnsubscribeCb; - componentWillMount() { - this.unsubscribe = db.active.subscribeAuthChange(userInfo => { - this.setState({ loadingAuth: false, userInfo }); - }); - } - - componentWillUnmount() { - this.unsubscribe(); - } - - render() { - console.log("document.location.pathname", document.location.pathname); - const page = route(document.location.pathname); - return h(page.root, { userInfo: this.state.userInfo }); - } -} - -export function route(pathname: string): Page { - for (const page of pages) { - if (pathname.match(page.route)) { - return page; - } - } - // TODO 404 page - return null; -} - -export const pages: Page[] = [ - { - title: "Propel ML", - path: "index.html", - root: Home, - route: /^\/(index.html)?$/ - }, - { - title: "Propel Notebook", - path: "notebook.html", - root: nb.NotebookRoot, - route: /^\/notebook/ - } -]; From f452ad08768834e6db8520a4fdffb1d181033d65 Mon Sep 17 00:00:00 2001 From: Parsa Ghadimi Date: Fri, 18 May 2018 11:19:09 +0430 Subject: [PATCH 04/10] Implement Propel router and some fixes in components --- src/app.tsx | 209 ++++++++++++++++++++++++++++++++++ src/components/menu.tsx | 1 + src/components/notebook.tsx | 19 ++-- src/components/user_title.tsx | 19 +--- src/main.tsx | 4 +- 5 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 src/app.tsx diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 00000000..77617e6e --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,209 @@ +/*! + Copyright 2018 Propel http://propel.site/. All rights reserved. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { Component, h } from "preact"; +import * as db from "./db"; +import { pushState, Router } from "./router"; +import * as types from "./types"; +import { equal } from "./util"; + +import { ErrorPage } from "./components/error"; +import { GlobalHeader } from "./components/header"; +import { Home } from "./components/home"; +import { Loading } from "./components/loading"; +import { UserMenu } from "./components/menu"; +import { Notebook } from "./components/notebook"; +import { Profile } from "./components/profile"; +import { Recent } from "./components/recent"; + +interface BindProps { + [key: string]: (props: any) => Promise; +} + +interface BindState { + data: { [key: string]: string }; + error: string; +} + +/** + * This react HOC can be used to bind result of some async + * methods to props of the given component (C). + * see: https://reactjs.org/docs/higher-order-components.html + * + * const newComponent = bind(Component, { + * async prop(props) { + * const re = await someAsyncActions(); + * return re; + * } + * }); + */ +function bind(C, bindProps: BindProps) { + return class extends Component { + state = { data: null, error: null }; + prevMatches = null; + + async loadData() { + if (equal(this.props.matches, this.prevMatches)) return; + this.prevMatches = this.props.matches; + const data = {}; + for (const key in bindProps) { + if (!bindProps[key]) continue; + try { + data[key] = await bindProps[key](this.props); + } catch (e) { + this.setState({ data: null, error: e.message }); + return; + } + } + this.setState({ data, error: null }); + } + + render() { + this.loadData(); + const { data, error } = this.state; + if (error) return ; + if (!data) return ; + return ; + } + }; +} + +// An anonymous notebook doc for when users aren't logged in +const anonDoc = { + anonymous: true, + cells: [], + created: new Date(), + owner: { + displayName: "Anonymous", + photoURL: require("./img/anon_profile.png"), + uid: "" + }, + title: "Anonymous Notebook", + updated: new Date() +}; + +// TODO Move these components to ./pages.tsx. +// tslint:disable:variable-name +async function onNewNotebook() { + const nbId = await db.active.create(); + // Redirect to new notebook. + pushState(`/notebook/${nbId}`); +} + +async function onOpenNotebook(nbId: string) { + // Redirect to notebook. + pushState(`/notebook/${nbId}`); +} + +export const RecentPage = bind(Recent, { + notebooks() { + return db.active.queryLatest(); + }, + async onNewNotebook() { + return () => onNewNotebook(); + }, + async onOpenNotebook() { + return (nbId: string) => onOpenNotebook(nbId); + } +}); + +export const ProfilePage = bind(Profile, { + notebooks(props) { + const uid = props.matches.userId; + return db.active.queryProfile(uid, 100); + }, + async onNewNotebook() { + return () => onNewNotebook(); + }, + async onOpenNotebook() { + return (nbId: string) => onOpenNotebook(nbId); + } +}); + +export const NotebookPage = bind(Notebook, { + initialDoc(props) { + const nbId = props.matches.nbId; + return nbId === "anonymous" + ? Promise.resolve(anonDoc) + : db.active.getDoc(nbId); + }, + save(props) { + const nbId = props.matches.nbId; + const cb = async doc => { + if (doc.anonymous) return; + if (!props.userInfo) return; + if (props.userInfo.uid !== doc.owner.uid) return; + try { + await db.active.updateDoc(nbId, doc); + } catch (e) { + // TODO + console.log(e); + } + }; + return Promise.resolve(cb); + }, + clone(props) { + const cb = async doc => { + const cloneId = await db.active.clone(doc); + // Redirect to new notebook. + pushState(`/notebook/${cloneId}`); + }; + return Promise.resolve(cb); + } +}); + +export const HomePage = bind(Home, {}); +// tslint:enable:variable-name + +export interface AppState { + loadingAuth: boolean; + userInfo: types.UserInfo; +} + +export class App extends Component<{}, AppState> { + state = { + loadingAuth: true, + userInfo: null + }; + + unsubscribe: db.UnsubscribeCb; + componentWillMount() { + this.unsubscribe = db.active.subscribeAuthChange(userInfo => { + this.setState({ loadingAuth: false, userInfo }); + }); + } + + componentWillUnmount() { + this.unsubscribe(); + } + + render() { + const { userInfo } = this.state; + return ( +
+ + + + + + + + + + +
+ ); + } +} diff --git a/src/components/menu.tsx b/src/components/menu.tsx index fea805bc..7e9a39e4 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -26,6 +26,7 @@ export interface UserMenuProps { export function UserMenu(props): JSX.Element { if (props.userInfo) { + // TODO "Your notebooks" link return (