diff --git a/.gitignore b/.gitignore index 1437c53..e928339 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ yarn-error.log* # vercel .vercel + +# WebStorm +.idea diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e9d185 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2019 Unly + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md index 514bf1d..16cea9c 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,76 @@ -# TypeScript Next.js example +# POC Next.js + Reaflow -This is a really simple project that shows the usage of Next.js with TypeScript. +> This project is a POC of [Reaflow](https://github.com/reaviz/reaflow) used with the Next.js framework. It is hosted on Vercel. -## Deploy your own +It is a single-page application (using a static page) that aims at showing an **advanced use-case with Reaflow**. + +## Online demo + +[Demo](https://poc-nextjs-reaflow.vercel.app/) (automatically updated from the `master` branch). + +![image](https://user-images.githubusercontent.com/3807458/109431687-08bf1680-7a08-11eb-98bd-31fa91e21680.png) -Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example): +## Features -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-typescript&project-name=with-typescript&repository-name=with-typescript) +It comes with the following features: +- Source code heavily **documented** +- Strong TS typings +- Different kinds of node (`start`, `if`, `information`, `question`) with different layouts for each type _(see [NodeRouter component](blob/main/src/components/nodes/NodeRouter.tsx))_ +- Nodes use `foreignObject`, which complicates things quite a bit (events, css), but it's the only way of writing HTML/CSS within an SVG `rect` (custom nodes UI) +- Advanced support for **`foreignObject`** and best-practices +- Support for **Emotion 11** +- Reaflow Nodes, Edges and Ports are properly extended (**BaseNode** component, **BaseNodeData** type, **BaseEdge** component, **BaseEdgeData** type, etc.), + which makes it easy to quickly change the properties of all nodes, edges, ports, etc. +- Creation of nodes through the `BlockPickerMenu` component, which displays either at the bottom of the canvas, or at the mouse pointer position (e.g: when dropping edges) +- **Undo/redo** support (with shortcuts) +- Node/edge **deletion** +- Node **duplication** +- **Selection** of nodes and edges, one at a time +- Uses **`Recoil`** for shared state management +- Automatically re-calculate the **height** of nodes when jumping lines in `textarea` +- Graph data (nodes, edges) are **persisted** in the browser **localstorage** and loaded upon page reload -## How to use it? +Known limitations: +- Editor direction is `RIGHT` (hardcoded) and adding nodes will add them to the right side, always (even if you change the direction) + - I don't plan on changing that at the moment -Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: +> This POC can be used as a boilerplate to start your own project using Reaflow. -```bash -npx create-next-app --example with-typescript with-typescript-app -# or -yarn create next-app --example with-typescript with-typescript-app -``` +## Getting started + +- `yarn` +- `yarn start` +- Open browser at [http://localhost:8890](http://localhost:8890) + +## Deploy your own -Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). +Deploy the example using [Vercel](https://vercel.com): -## Notes +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/Vadorequest/poc-nextjs-reaflow&project-name=poc-nextjs-reaflow&repository-name=poc-nextjs-reaflow) -This example shows how to integrate the TypeScript type system into Next.js. Since TypeScript is supported out of the box with Next.js, all we have to do is to install TypeScript. +## Advanced - ELK -``` -npm install --save-dev typescript -``` +ELKjs (and ELK) are used to draw the graph (nodes, edges). +It's what Reaflow uses in the background. +ELK stands for **Eclipse Layout Kernel**. -To enable TypeScript's features, we install the type declarations for React and Node. +It seems to be one of the best Layout manager out there. -``` -npm install --save-dev @types/react @types/react-dom @types/node -``` +Unfortunately, it is quite complicated and lacks a comprehensive documentation. -When we run `next dev` the next time, Next.js will start looking for any `.ts` or `.tsx` files in our project and builds it. It even automatically creates a `tsconfig.json` file for our project with the recommended settings. +You'll need to dig into the ELK documentation and issues if you're trying to change **how the graph's layout behaves**. +Here are some good places to start and useful links I've compiled for my own sake. -Next.js has built-in TypeScript declarations, so we'll get autocompletion for Next.js' modules straight away. +- [ELKjs GitHub](https://github.com/kieler/elkjs) +- [ELK official website](https://www.eclipse.org/elk/) +- [ELK Demonstrators](https://rtsys.informatik.uni-kiel.de/elklive/index.html) + - [Tool to convert `elkt <=> json` both ways](https://rtsys.informatik.uni-kiel.de/elklive/conversion.html) + - [Tool to convert `elkt` to a graph](https://rtsys.informatik.uni-kiel.de/elklive/elkgraph.html) + - [Java ELK implementation of the `layered` algorithm](https://github.com/eclipse/elk/tree/master/plugins/org.eclipse.elk.alg.layered/src/org/eclipse/elk/alg/layered/p2layers) + - [Community examples soure code](https://github.com/eclipse/elk-models/tree/master/examples) _(which are displayed on [ELK examples](https://rtsys.informatik.uni-kiel.de/elklive/examples.html))_ + - [Klayjs example](http://kieler.github.io/klayjs-d3/examples/interactive) (ELK is the sucessor of KlayJS and [should support the same options](https://github.com/kieler/elkjs/issues/122#issuecomment-777781503)) +- [Issues opened by Austin](https://github.com/kieler/elkjs/issues?q=is%3Aissue+sort%3Aupdated-desc+author%3Aamcdnl) +- [Issues opened by Vadorequest](https://github.com/kieler/elkjs/issues?q=is%3Aissue+sort%3Aupdated-desc+author%3Avadorequest) -A `type-check` script is also added to `package.json`, which runs TypeScript's `tsc` CLI in `noEmit` mode to run type-checking separately. You can then include this, for example, in your `test` scripts. +Known limitations: +- [Tracking issue - Manually positioning the nodes ("Standalone Edge Routing")](https://github.com/eclipse/elk/issues/315) diff --git a/app.d.ts b/app.d.ts new file mode 100644 index 0000000..7398efe --- /dev/null +++ b/app.d.ts @@ -0,0 +1,2 @@ +declare module '@unly/utils'; + diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..9ca5a22 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,24 @@ +/** + * Babel configuration for Next.js + * + * The official documentation uses a ".babelrc" file, but we prefer using "babel.config.js" for better documentation support. + * + * @see https://nextjs.org/docs/advanced-features/customizing-babel-config Official doc reference v10 + * @see https://github.com/vercel/next.js/blob/canary/packages/next/build/babel/preset.ts You can take a look at this file to learn about the presets included by next/babel. + * @see https://emotion.sh/docs/css-prop#babel-preset Configuring Emotion 11 + * @example https://github.com/vercel/next.js/tree/canary/examples/with-custom-babel-config Next.js official example of customizing Babel + */ +module.exports = { + presets: [ + [ + 'next/babel', + { + 'preset-react': { + 'runtime': 'automatic', + 'importSource': '@emotion/react', + }, + }, + ], + ], + plugins: ['@emotion/babel-plugin'], +}; diff --git a/browser.d.ts b/browser.d.ts new file mode 100644 index 0000000..1f2c2e6 --- /dev/null +++ b/browser.d.ts @@ -0,0 +1,6 @@ +/** + * Extends the native browser Window object by adding our custom keys. + */ +interface Window { + initialCanvasDataset?: any; +} diff --git a/components/Layout.tsx b/components/Layout.tsx deleted file mode 100644 index 8f111e1..0000000 --- a/components/Layout.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { ReactNode } from 'react' -import Link from 'next/link' -import Head from 'next/head' - -type Props = { - children?: ReactNode - title?: string -} - -const Layout = ({ children, title = 'This is the default title' }: Props) => ( -
- - {title} - - - -
- -
- {children} - -
-) - -export default Layout diff --git a/components/List.tsx b/components/List.tsx deleted file mode 100644 index 5fe5ef2..0000000 --- a/components/List.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from 'react' -import ListItem from './ListItem' -import { User } from '../interfaces' - -type Props = { - items: User[] -} - -const List = ({ items }: Props) => ( - -) - -export default List diff --git a/components/ListDetail.tsx b/components/ListDetail.tsx deleted file mode 100644 index fa3f9c3..0000000 --- a/components/ListDetail.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react' - -import { User } from '../interfaces' - -type ListDetailProps = { - item: User -} - -const ListDetail = ({ item: user }: ListDetailProps) => ( -
-

Detail for {user.name}

-

ID: {user.id}

-
-) - -export default ListDetail diff --git a/components/ListItem.tsx b/components/ListItem.tsx deleted file mode 100644 index b10b9c8..0000000 --- a/components/ListItem.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' -import Link from 'next/link' - -import { User } from '../interfaces' - -type Props = { - data: User -} - -const ListItem = ({ data }: Props) => ( - - - {data.id}: {data.name} - - -) - -export default ListItem diff --git a/interfaces/index.ts b/interfaces/index.ts deleted file mode 100644 index 68528c5..0000000 --- a/interfaces/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// You can include shared interfaces/types in a separate file -// and then use them in any component by importing them. For -// example, to import the interface below do: -// -// import { User } from 'path/to/interfaces'; - -export type User = { - id: number - name: string -} diff --git a/package.json b/package.json index 71b0fd8..b1f089b 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,73 @@ { - "name": "with-typescript", - "version": "1.0.0", + "name": "poc-nextjs-reaflow", + "license": "MIT", "scripts": { - "dev": "next", "build": "next build", - "start": "next start", - "type-check": "tsc" + "start": "next dev --port 8890", + "type-check": "tsc", + "link:reaflow": "yarn link reaflow && yarn link react && yarn link react-dom" }, "dependencies": { - "next": "latest", - "react": "^16.12.0", - "react-dom": "^16.12.0" + "@chakra-ui/icons": "1.0.4", + "@chakra-ui/react": "1.2.1", + "@chakra-ui/theme-tools": "1.0.3", + "@emotion/react": "11.1.4", + "@emotion/styled": "11.0.0", + "@fortawesome/fontawesome-svg-core": "1.2.34", + "@fortawesome/free-brands-svg-icons": "5.15.2", + "@fortawesome/free-solid-svg-icons": "5.15.2", + "@fortawesome/react-fontawesome": "0.1.14", + "@types/lodash.clonedeep": "4.5.6", + "@types/lodash.merge": "4.6.6", + "@types/lodash.now": "4.0.6", + "@types/lodash.size": "4.2.6", + "@unly/utils": "1.0.3", + "@welldone-software/why-did-you-render": "6.0.5", + "animate.css": "4.1.1", + "classnames": "2.2.6", + "framer-motion": "3.2.1", + "lodash.capitalize": "4.2.1", + "lodash.clonedeep": "4.5.0", + "lodash.debounce": "4.0.8", + "lodash.filter": "4.6.0", + "lodash.includes": "4.3.0", + "lodash.isequal": "4.5.0", + "lodash.merge": "4.6.2", + "lodash.now": "4.0.2", + "lodash.remove": "4.7.0", + "lodash.size": "4.2.0", + "lodash.some": "4.6.0", + "lodash.sortby": "4.7.0", + "next": "10.0.6", + "rdk": "5.0.6", + "react": "17.0.1", + "react-debounce-input": "3.2.3", + "react-dom": "17.0.1", + "react-select": "4.0.2", + "react-textarea-autosize": "8.3.0", + "reaflow": "3.0.13", + "recoil": "0.1.2", + "recoil-devtools-dock": "^0.1.6", + "recoil-devtools-log-monitor": "^0.2.7", + "recoil-devtools-logger": "^0.1.5", + "uuid": "8.3.2" }, "devDependencies": { - "@types/node": "^12.12.21", - "@types/react": "^16.9.16", - "@types/react-dom": "^16.9.4", - "typescript": "4.0" - }, - "license": "MIT" + "@emotion/babel-plugin": "11.1.2", + "@types/classnames": "2.2.11", + "@types/lodash.capitalize": "4.2.6", + "@types/lodash.debounce": "4.0.6", + "@types/lodash.filter": "4.6.6", + "@types/lodash.includes": "4.3.6", + "@types/lodash.isequal": "4.5.5", + "@types/lodash.remove": "4.7.6", + "@types/lodash.some": "4.6.6", + "@types/lodash.sortby": "4.7.6", + "@types/node": "14.14.22", + "@types/react": "17.0.1", + "@types/react-dom": "17.0.0", + "@types/react-select": "4.0.12", + "@types/uuid": "8.3.0", + "typescript": "4.1.3" + } } diff --git a/pages/about.tsx b/pages/about.tsx deleted file mode 100644 index 4e6f301..0000000 --- a/pages/about.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import Link from 'next/link' -import Layout from '../components/Layout' - -const AboutPage = () => ( - -

About

-

This is the about page

-

- - Go home - -

-
-) - -export default AboutPage diff --git a/pages/api/users/index.ts b/pages/api/users/index.ts deleted file mode 100644 index 4efdba6..0000000 --- a/pages/api/users/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next' -import { sampleUserData } from '../../../utils/sample-data' - -const handler = (_req: NextApiRequest, res: NextApiResponse) => { - try { - if (!Array.isArray(sampleUserData)) { - throw new Error('Cannot find user data') - } - - res.status(200).json(sampleUserData) - } catch (err) { - res.status(500).json({ statusCode: 500, message: err.message }) - } -} - -export default handler diff --git a/pages/index.tsx b/pages/index.tsx deleted file mode 100644 index 57f913a..0000000 --- a/pages/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import Link from 'next/link' -import Layout from '../components/Layout' - -const IndexPage = () => ( - -

Hello Next.js đź‘‹

-

- - About - -

-
-) - -export default IndexPage diff --git a/pages/users/[id].tsx b/pages/users/[id].tsx deleted file mode 100644 index 61720da..0000000 --- a/pages/users/[id].tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { GetStaticProps, GetStaticPaths } from 'next' - -import { User } from '../../interfaces' -import { sampleUserData } from '../../utils/sample-data' -import Layout from '../../components/Layout' -import ListDetail from '../../components/ListDetail' - -type Props = { - item?: User - errors?: string -} - -const StaticPropsDetail = ({ item, errors }: Props) => { - if (errors) { - return ( - -

- Error: {errors} -

-
- ) - } - - return ( - - {item && } - - ) -} - -export default StaticPropsDetail - -export const getStaticPaths: GetStaticPaths = async () => { - // Get the paths we want to pre-render based on users - const paths = sampleUserData.map((user) => ({ - params: { id: user.id.toString() }, - })) - - // We'll pre-render only these paths at build time. - // { fallback: false } means other routes should 404. - return { paths, fallback: false } -} - -// This function gets called at build time on server-side. -// It won't be called on client-side, so you can even do -// direct database queries. -export const getStaticProps: GetStaticProps = async ({ params }) => { - try { - const id = params?.id - const item = sampleUserData.find((data) => data.id === Number(id)) - // By returning { props: item }, the StaticPropsDetail component - // will receive `item` as a prop at build time - return { props: { item } } - } catch (err) { - return { props: { errors: err.message } } - } -} diff --git a/pages/users/index.tsx b/pages/users/index.tsx deleted file mode 100644 index 7b3ee88..0000000 --- a/pages/users/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { GetStaticProps } from 'next' -import Link from 'next/link' - -import { User } from '../../interfaces' -import { sampleUserData } from '../../utils/sample-data' -import Layout from '../../components/Layout' -import List from '../../components/List' - -type Props = { - items: User[] -} - -const WithStaticProps = ({ items }: Props) => ( - -

Users List

-

- Example fetching data from inside getStaticProps(). -

-

You are currently on: /users

- -

- - Go home - -

-
-) - -export const getStaticProps: GetStaticProps = async () => { - // Example for including static props in a Next.js function component page. - // Don't forget to include the respective types for any props passed into - // the component. - const items: User[] = sampleUserData - return { props: { items } } -} - -export default WithStaticProps diff --git a/src/components/DisplayOnBrowserMount.tsx b/src/components/DisplayOnBrowserMount.tsx new file mode 100644 index 0000000..9bb7e3b --- /dev/null +++ b/src/components/DisplayOnBrowserMount.tsx @@ -0,0 +1,86 @@ +import size from 'lodash.size'; +import some from 'lodash.some'; +import React, { + DependencyList, + Fragment, + useState, +} from 'react'; + +export type Props = { + children: React.ReactNode; + deps?: DependencyList; +}; + +/** + * Utility component to properly handle expected differences between server and browser rendering. + * Helps to avoid "Text content did not match" warnings, during React rehydration. + * + * Optionally accepts a "deps" array of dependencies. + * It can be used to optimize behaviour to support both SSG/SSR universally with a different behaviour based on the rendering mode. + * If any deps is provided, then it'll check if any is null-ish (undefined/null) + * If so, it will render "null" and trigger a re-render, because in such case we consider the deps aren't fulfilled + * If all deps are defined, then we render the children directly because we consider we don't need to wait (optimisation, no unnecessary re-render) + * + * @example If you want to display a cookie's value universally: + * - If you use SSR, you'll have access to the cookie's value from the server and want to render it immediately + * - If you use SSG, you won't have access to the cookie's value until the browser renders the page, so you want to render "null" and then trigger a re-render to display the actual value + * + * XXX Use this helper to avoid rendering small UI (presentational) components that depend on browser-related data (e.g: localStorage, cookie, session-related data, etc.) + * Do not use this helper to avoid rendering big react Providers, or components who define big part of your UI layout + * + * When a React app rehydrates, it assumes that the DOM structure will match. + * When the React app runs on the client for the first time, it builds up a mental picture of what the DOM should look like, by mounting all of your components. + * Then it squints at the DOM nodes already on the page, and tries to fit the two together. + * It's not playing the “spot-the-differences” game it does during a typical update, it's just trying to snap the two together, so that future updates will be handled correctly. + * + * If you render something different depending on whether you're on server or browser, you're hacking the system. + * Because you're rendering one thing on the server, but then telling React to expect something else on the client. + * And this is what causes "Text content did not match" react warning. + * + * To avoid this situation, we use "useEffect", which only fires after the component has mounted. + * Inside the "useEffect" call, we immediately trigger a re-render, setting hasMounted to true. When this value is true, the "real" content gets rendered. + * When the React app adapts the DOM during rehydration, useEffect hasn't been called yet, and so we're meeting React's expectation. + * + * This process is named "Two pass rendering": + * The first pass, at compile-time, produces all of the static non-personal content, and leaves holes where the dynamic content will go. + * Then, after the React app has mounted on the user's device, a second pass stamps in all the dynamic bits that depend on client state. + * The downside to two-pass rendering is that it can waitFor time-to-interactive. + * + * @param props + * @see https://joshwcomeau.com/react/the-perils-of-rehydration/#abstractions Strongly inspired by "ClientOnly" abstraction + * @see https://joshwcomeau.com/react/the-perils-of-rehydration/#two-pass-rendering Two pass rendering and performances implications + * @see https://twitter.com/Vadorequest/status/1257658553361408002 Discussion with Josh regarding advanced usage + */ +const DisplayOnBrowserMount: React.FunctionComponent = (props) => { + const { + children, + deps = [], + } = props; + // If any dep isn't defined, then it will render "null" first, and then trigger a re-render + const isAnyDepsNullish = size(deps) ? + // If any deps was provided, check if any is null-ish + some(deps, (dependency: any): boolean => { + return dependency === null || typeof dependency === 'undefined'; + }) + // If no dep is provided, then it should render "null" first anyway, and then trigger a re-render + : true; + const [hasMounted, setHasMounted] = useState(!isAnyDepsNullish); + + React.useEffect(() => { + if (isAnyDepsNullish) { + setHasMounted(true); + } + }, [isAnyDepsNullish]); + + if (!hasMounted) { + return null; + } + + return ( + + {children} + + ); +}; + +export default DisplayOnBrowserMount; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..35ad0c1 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,92 @@ +import { + ExternalLinkIcon, + QuestionOutlineIcon, +} from '@chakra-ui/icons'; +import { + Box, + Button, + Flex, + Link as ChakraLink, + Spacer, + Tag, + TagLabel, + Tooltip, +} from '@chakra-ui/react'; +import { css } from '@emotion/react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React from 'react'; +import settings from '../settings'; + +type Props = {} + +const Footer: React.FunctionComponent = (props) => { + return ( +
+ + + Demo: + + + With Local storage + + + + + + + Made with + + {' '}Next.js{' '} + + + {' '}and{' '} + + {' '}Reaflow{' '} + + + + + + + + + + {' '} + GitHub + + + + +
+ ); +}; + +export default Footer; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..14303c7 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,39 @@ +import Head from 'next/head'; +import React, { ReactNode } from 'react'; +import Footer from './Footer'; +import Nav from './Nav'; + +type Props = { + children?: ReactNode + title?: string +} + +/** + * Layout meant to be shared by all Next.js pages. + * + * Simply displays a header, footer, and the Next.js page in between. + */ +const Layout: React.FunctionComponent = (props) => { + const { + children, + title = 'POC Next.js + Reaflow', + } = props; + + return ( +
+ + {title} + + + + +
+ ); +}; + +export default Layout; diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx new file mode 100644 index 0000000..8264ff5 --- /dev/null +++ b/src/components/Nav.tsx @@ -0,0 +1,36 @@ +import { + Box, + Center, +} from '@chakra-ui/react'; +import { css } from '@emotion/react'; +import React from 'react'; +import settings from '../settings'; + +type Props = {} + +const Nav: React.FunctionComponent = (props) => { + return ( +
+ +
+ ); +}; + +export default Nav; diff --git a/src/components/RecoilDevtools.tsx b/src/components/RecoilDevtools.tsx new file mode 100644 index 0000000..a634686 --- /dev/null +++ b/src/components/RecoilDevtools.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import DockMonitor from 'recoil-devtools-dock'; +import LogMonitor from 'recoil-devtools-log-monitor'; +import { RecoilLogger } from 'recoil-devtools-logger'; +import DisplayOnBrowserMount from './DisplayOnBrowserMount'; + +/** + * Adds Recoil dev tools for easier understanding and debug of what happens in the Recoil store. + * Enabled in development mode only. + * + * @see https://github.com/ulises-jeremias/recoil-devtools + */ +export const RecoilDevtools = () => { + if (process.env.NODE_ENV !== 'development') { + return null; + } + + return ( + + + + + + + ); +}; diff --git a/src/components/RecoilExternalStatePortal.tsx b/src/components/RecoilExternalStatePortal.tsx new file mode 100644 index 0000000..c4581cb --- /dev/null +++ b/src/components/RecoilExternalStatePortal.tsx @@ -0,0 +1,65 @@ +import { + Loadable, + RecoilState, + RecoilValue, + useRecoilCallback, + useRecoilTransactionObserver_UNSTABLE, +} from 'recoil'; + +/** + * Returns a Recoil state value, from anywhere in the app. + * + * Can be used outside of the React tree (outside a React component), such as in utility scripts, etc. + + * must have been previously loaded in the React tree, or it won't work. + * Initialized as a dummy function "() => null", it's reference is updated to a proper Recoil state mutator when RecoilExternalStatePortal is loaded. + * + * @example const lastCreatedUser = getRecoilExternalLoadable(lastCreatedUserState); + */ +export let getRecoilExternalLoadable: ( + recoilValue: RecoilValue, +) => Loadable = () => null as any; + +/** + * Sets a Recoil state value, from anywhere in the app. + * + * Can be used outside of the React tree (outside a React component), such as in utility scripts, etc. + * + * must have been previously loaded in the React tree, or it won't work. + * Initialized as a dummy function "() => null", it's reference is updated to a proper Recoil state mutator when RecoilExternalStatePortal is loaded. + * + * @example setRecoilExternalState(lastCreatedUserState, newUser) + */ +export let setRecoilExternalState: ( + recoilState: RecoilState, + valOrUpdater: ((currVal: T) => T) | T, +) => void = () => null as any; + +/** + * Utility component allowing to use the Recoil state outside of a React component. + * + * It must be loaded in the _app file, inside the component. + * Once it's been loaded in the React tree, it allows using setRecoilExternalState and getRecoilExternalLoadable from anywhere in the app. + * + * @see https://github.com/facebookexperimental/Recoil/issues/289#issuecomment-777300212 + * @see https://github.com/facebookexperimental/Recoil/issues/289#issuecomment-777305884 + * @see https://recoiljs.org/docs/api-reference/core/Loadable/ + */ +export function RecoilExternalStatePortal() { + // We need to update the getRecoilExternalLoadable every time there's a new snapshot + // Otherwise we will load old values from when the component was mounted + useRecoilTransactionObserver_UNSTABLE(({ snapshot }) => { + getRecoilExternalLoadable = snapshot.getLoadable; + }); + + // We only need to assign setRecoilExternalState once because it's not temporally dependent like "get" is + useRecoilCallback(({ set }) => { + setRecoilExternalState = set; + + return async () => { + + }; + })(); + + return <>; +} diff --git a/src/components/blocks/BasePreviewBlock.tsx b/src/components/blocks/BasePreviewBlock.tsx new file mode 100644 index 0000000..a5af018 --- /dev/null +++ b/src/components/blocks/BasePreviewBlock.tsx @@ -0,0 +1,26 @@ +import styled from '@emotion/styled'; + +/** + * Base style used by all preview blocks. + */ +const BasePreviewBlock = styled.button` + height: 100px; + width: 100px; + cursor: grab; + background: #f5f5f5; + color: black; + margin-right: 10px; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + flex: 1; +`; + +export default BasePreviewBlock; diff --git a/src/components/blocks/BlockPickerMenu.tsx b/src/components/blocks/BlockPickerMenu.tsx new file mode 100644 index 0000000..73a23cb --- /dev/null +++ b/src/components/blocks/BlockPickerMenu.tsx @@ -0,0 +1,121 @@ +import { css } from '@emotion/react'; +import classnames from 'classnames'; +import React, { + useEffect, + useState, +} from 'react'; +import { useRecoilState } from 'recoil'; +import { blockPickerMenuSelector } from '../../states/blockPickerMenuState'; +import { OnBlockClick } from '../../types/BlockPickerMenu'; +import NodeType from '../../types/NodeType'; +import IfBlock from './IfBlock'; +import InformationBlock from './InformationBlock'; +import QuestionBlock from './QuestionBlock'; + +type Props = {}; + +/** + * Menu where the editor can select (pick) a block amongst a list of blocks. + * + * The menu is always displayed using an absolute position, on top of the canvas. + * It can be displayed either at the bottom of the canvas, or at a specific location. + * It is usually displayed at the drop location, when dropping an edge. + */ +const BlockPickerMenu: React.FunctionComponent = (props) => { + const [blockPickerMenu, setBlockPickerMenu] = useRecoilState(blockPickerMenuSelector); + const { + onBlockClick, + isDisplayed, + displayedFrom, + top, + left, + } = blockPickerMenu; + + const [repeatAnimation, setRepeatAnimation] = useState(true); + + /** + * When the source displaying the menu changes, replay the animations of the component. + * + * It toggles the CSS classes injected in the component to force replaying the animations. + * Uses a short timeout that isn't noticeable to the human eye, but is necessary for the toggle to work properly. + */ + useEffect(() => { + setRepeatAnimation(false); + setTimeout(() => setRepeatAnimation(true), 1); + }, [displayedFrom]); + + if (!isDisplayed) { + return null; + } + + // console.log('displaying BlockPickerMenu'); + + /** + * Called by the specialized blocks upon click. + * + * Contains "onClick" business logic that is shared by all blocks. + * + * @param nodeType + */ + const onSpecializedBlockClick: OnBlockClick = (nodeType: NodeType) => { + if (onBlockClick) { + // We provide the "blockPickerMenu" so that it is always up-to-date + // Otherwise, it would be out-of-date when relying on the blockPickerMenu that was bound when creating the onBlockClick function + onBlockClick(nodeType, blockPickerMenu); + + // Automatically hide the block picker menu once a block has been picked + setBlockPickerMenu({ + isDisplayed: false, + }); + } + }; + + return ( +
+
+ + + +
+
+ ); +}; + +export default BlockPickerMenu; diff --git a/src/components/blocks/IfBlock.tsx b/src/components/blocks/IfBlock.tsx new file mode 100644 index 0000000..726d401 --- /dev/null +++ b/src/components/blocks/IfBlock.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import BaseBlockComponent from '../../types/BaseBlockComponent'; +import { OnBlockClick } from '../../types/BlockPickerMenu'; + +type Props = { + onBlockClick: OnBlockClick; +}; + +/** + * When clicking on this block (from the ), it creates a component in the canvas. + */ +const IfBlock: BaseBlockComponent = (props) => { + const { + onBlockClick, + } = props; + + const onClick = () => { + onBlockClick('if'); + }; + + return ( +
+ If/Else +
+ ); +}; + +export default IfBlock; diff --git a/src/components/blocks/InformationBlock.tsx b/src/components/blocks/InformationBlock.tsx new file mode 100644 index 0000000..027a098 --- /dev/null +++ b/src/components/blocks/InformationBlock.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import BaseBlockComponent from '../../types/BaseBlockComponent'; +import { OnBlockClick } from '../../types/BlockPickerMenu'; + +type Props = { + onBlockClick: OnBlockClick; +}; + +/** + * When clicking on this block (from the ), it creates a component in the canvas. + */ +const InformationBlock: BaseBlockComponent = (props) => { + const { + onBlockClick, + } = props; + + const onClick = () => { + onBlockClick('information'); + }; + + return ( +
+ Information +
+ ); +}; + +export default InformationBlock; diff --git a/src/components/blocks/QuestionBlock.tsx b/src/components/blocks/QuestionBlock.tsx new file mode 100644 index 0000000..9a8cf89 --- /dev/null +++ b/src/components/blocks/QuestionBlock.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import BaseBlockComponent from '../../types/BaseBlockComponent'; +import { OnBlockClick } from '../../types/BlockPickerMenu'; + +type Props = { + onBlockClick: OnBlockClick; +}; + +/** + * When clicking on this block (from the ), it creates a component in the canvas. + */ +const QuestionBlock: BaseBlockComponent = (props) => { + const { + onBlockClick, + } = props; + + const onClick = () => { + onBlockClick('question'); + }; + + return ( +
+ Question +
+ ); +}; + +export default QuestionBlock; diff --git a/src/components/context/canvasUtilsContext.tsx b/src/components/context/canvasUtilsContext.tsx new file mode 100644 index 0000000..c04972d --- /dev/null +++ b/src/components/context/canvasUtilsContext.tsx @@ -0,0 +1,53 @@ +import React, { RefObject } from 'react'; + +/** + * Contains utilities exposed by the Canvas ref. + * + * Contains utilities from both LayoutResult and ZoomResult. (built-in in Reaflow) + */ +export type CanvasUtilsContext = { + + /** + * Ref to container div. + */ + containerRef?: RefObject; + + /** + * Center the canvas to the viewport. + */ + centerCanvas?: () => void; + + /** + * Fit the canvas to the viewport. + */ + fitCanvas?: () => void; + + /** + * Set a zoom factor of the canvas. + */ + setZoom?: (factor: number) => void; + + /** + * Zoom in on the canvas. + */ + zoomIn?: () => void; + + /** + * Zoom out on the canvas. + */ + zoomOut?: () => void; +} + +/** + * Uses native React Context API, meant to be used from hooks only, not by functional components + * + * @example Usage + * import canvasUtilsContext from './src/stores/canvasUtilsContext'; + * const { containerRef }: CanvasContext = React.useContext(canvasUtilsContext); + * + * @see https://reactjs.org/docs/context.html + * @see https://medium.com/better-programming/react-hooks-usecontext-30eb560999f for useContext hook example (open in anonymous browser #paywall) + */ +export const canvasUtilsContext = React.createContext({}); + +export default canvasUtilsContext; diff --git a/src/components/edges/BaseEdge.tsx b/src/components/edges/BaseEdge.tsx new file mode 100644 index 0000000..d45aa47 --- /dev/null +++ b/src/components/edges/BaseEdge.tsx @@ -0,0 +1,180 @@ +import classnames from 'classnames'; +import cloneDeep from 'lodash.clonedeep'; +import now from 'lodash.now'; +import React from 'react'; +import { + Edge, + EdgeData, +} from 'reaflow'; +import { + SetterOrUpdater, + useRecoilState, + useSetRecoilState, +} from 'recoil'; +import { blockPickerMenuSelector } from '../../states/blockPickerMenuState'; +import { canvasDatasetSelector } from '../../states/canvasDatasetSelector'; +import { edgesSelector } from '../../states/edgesState'; +import { lastCreatedState } from '../../states/lastCreatedState'; +import { selectedEdgesSelector } from '../../states/selectedEdgesState'; +import { selectedNodesSelector } from '../../states/selectedNodesState'; +import BaseEdgeData from '../../types/BaseEdgeData'; +import BaseEdgeProps from '../../types/BaseEdgeProps'; +import BaseNodeData from '../../types/BaseNodeData'; +import BasePortData from '../../types/BasePortData'; +import BlockPickerMenu, { OnBlockClick } from '../../types/BlockPickerMenu'; +import { CanvasDataset } from '../../types/CanvasDataset'; +import { LastCreated } from '../../types/LastCreated'; +import NodeType from '../../types/NodeType'; +import { translateXYToCanvasPosition } from '../../utils/canvas'; +import { + createNodeFromDefaultProps, + getDefaultNodePropsWithFallback, + upsertNodeThroughPorts, +} from '../../utils/nodes'; +import EdgeActions from './EdgeActions'; + +type Props = {} & BaseEdgeProps; + +/** + * Base edge component. + * + * This component contains shared business logic common to all edges. + * It renders a Reaflow component. + * + * The Edge renders itself as SVG HTML element wrapper, which contains the HTML element that displays the link itself. + * + * @see https://reaflow.dev/?path=/story/demos-edges + */ +const BaseEdge: React.FunctionComponent = (props) => { + const { + id, + source: sourceNodeId, + sourcePort: sourcePortId, + target: targetNodeId, + targetPort: targetPortId, + } = props; + + const [blockPickerMenu, setBlockPickerMenu] = useRecoilState(blockPickerMenuSelector); + const [canvasDataset, setCanvasDataset] = useRecoilState(canvasDatasetSelector); + const [edges, setEdges] = useRecoilState(edgesSelector); + const { nodes } = canvasDataset; + const setLastCreatedNode: SetterOrUpdater = useSetRecoilState(lastCreatedState); + const { displayedFrom, isDisplayed } = blockPickerMenu; + const edge: BaseEdgeData = edges.find((edge: BaseEdgeData) => edge?.id === id) as BaseEdgeData; + const [selectedEdges, setSelectedEdges] = useRecoilState(selectedEdgesSelector); + const [selectedNodes, setSelectedNodes] = useRecoilState(selectedNodesSelector); + + if (typeof edge === 'undefined') { + return null; + } + + // Resolve instances of connected nodes and ports + const sourceNode: BaseNodeData | undefined = nodes.find((node: BaseNodeData) => node.id === sourceNodeId); + const sourcePort: BasePortData | undefined = sourceNode?.ports?.find((port: BasePortData) => port.id === sourcePortId); + const targetNode: BaseNodeData | undefined = nodes.find((node: BaseNodeData) => node.id === targetNodeId); + const targetPort: BasePortData | undefined = targetNode?.ports?.find((port: BasePortData) => port.id === targetPortId); + const isSelected = !!selectedEdges?.find((selectedEdge: string) => selectedEdge === edge.id); + + // console.log('edgeProps', props); + + /** + * Invoked when clicking on the "+" of the edge. + * + * Displays the BlockPickerMenu, which can then be used to select which Block to add to the canvas. + * If the BlockPickerMenu was already displayed, hides it if it was opened from the same edge. + * + * When a block is clicked, the "onBlockClick" function is invoked and creates (upserts) the node + * by splitting the edge in two parts and adding the new node in between. + * + * @param event + * @param edge_DO_NOT_USE + */ + const onAddIconClick = (event: React.MouseEvent, edge_DO_NOT_USE: EdgeData): void => { + console.log('onAdd edge', edge, event); + const onBlockClick: OnBlockClick = (nodeType: NodeType) => { + console.log('onBlockClick (from edge add)', nodeType, edge); + const newNode: BaseNodeData = createNodeFromDefaultProps(getDefaultNodePropsWithFallback(nodeType)); + const newDataset: CanvasDataset = upsertNodeThroughPorts(cloneDeep(nodes), cloneDeep(edges), edge, newNode); + + setCanvasDataset(newDataset); + setLastCreatedNode({ node: newNode, at: now() }); + setSelectedNodes([newNode?.id]); + setSelectedEdges([]); + }; + + // Converts the x/y position to a Canvas position and apply some margin for the BlockPickerMenu to display on the right bottom of the cursor + const [x, y] = translateXYToCanvasPosition(event.clientX, event.clientY, { left: 15, top: 15 }); + + setBlockPickerMenu({ + displayedFrom: `edge-${edge.id}`, + // Toggles on click if the source is the same, otherwise update + isDisplayed: displayedFrom === `edge-${edge.id}` ? !isDisplayed : true, + onBlockClick, + eventTarget: event.target, + top: y, + left: x, + }); + }; + + /** + * Invoked when clicking on the "-" of the edge. + * + * Removes the selected edge. + * + * @param event + * @param edge + */ + const onRemoveIconClick = (event: React.MouseEvent, edge: EdgeData): void => { + console.log('onRemoveIconClick', event, edge); + setEdges(edges.filter((edge: BaseEdgeData) => edge.id !== id)); + }; + + /** + * Selects the edge when clicking on it. + * + * XXX We're resolving the "edge" ourselves, instead of relying on the 2nd argument (edgeData), + * which doesn't contain all the expected properties. It is more reliable to use the current edge, which already known. + * + * @param event + * @param data_DO_NOT_USE + */ + const onEdgeClick = (event: React.MouseEvent, data_DO_NOT_USE: BaseEdgeData) => { + console.log('onEdgeClick', event, edge); + setSelectedEdges([edge.id]); + }; + + return ( +