diff --git a/.env.local.example b/.env.local.example index 770f76c..a3be955 100644 --- a/.env.local.example +++ b/.env.local.example @@ -6,6 +6,11 @@ # Go to https://dashboard.magic.link/ > API Keys > Test "Publishable key" NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY= +# FaunaDB token to generate from FaunaDB, based on the "Public" role. +# Go to https://dashboard.fauna.com/ > Select DB > Shell > Run fql/setup.js (if not done already) +# Go to https://dashboard.fauna.com/ > Select DB > Security > New Key > Role: Public | Name: PUBLIC_SHARED_FAUNABD_TOKEN +NEXT_PUBLIC_SHARED_FAUNABD_TOKEN= + # Magic Link provides a "secret key" which must only be used from the server. # Go to https://dashboard.magic.link/ > API Keys > Test "Secret key" MAGIC_SECRET_KEY= @@ -14,3 +19,8 @@ MAGIC_SECRET_KEY= # Changing this secret will invalidate all existing user sessions. (they'll have to log in again) # You can generate a string using https://passwordsgenerator.net/ (recommended 32 chars, no special chars) CRYPTO_TOKEN_SECRET= + +# Server secret key for FaunaDB. +# Used to perform actions from the server-side (creating users, etc.) +# Go to https://dashboard.fauna.com/ > Select DB > Security > New Key > Role: Server | Name: FAUNADB_SERVER_SECRET_KEY +FAUNADB_SERVER_SECRET_KEY= diff --git a/README.md b/README.md index 9420452..e6e64a5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,17 @@ -# POC Next.js + Reaflow +# RWA FaunaDB + Reaflow + Next.js + Magic link -> This project is a POC of [Reaflow](https://github.com/reaviz/reaflow) used with the Next.js framework. It is hosted on Vercel. +This project is a Real-World App featuring [FaunaDB](https://fauna.com/) as real-time database, [Reaflow](https://github.com/reaviz/reaflow) as graph editor, and [Magic Link](https://magic.link/) for passwordless authentication. -It is a single-page application (using a static page) that aims at showing an **advanced use-case with Reaflow**. +It also uses the famous Next.js framework, and it's hosted on Vercel. + +This RWA is meant to help beginners with any of the above-listed tools learn how to build a real app, using best-practices. +Therefore, the codebase is heavily documented, not only the README but also every file in the project. + +Take a look at the **[Variants](#Variants)** below **before jumping in the source code**. +As part of my developer journey, I've reached different milestones and made different branches/PR for each of them. +If you're only interested in Reaflow, or Magic Auth, or FaunaDB Real-Time streaming, **they'll help you focus on what's of the most interest to you**. + +> _If you like what you're seeing, take a look at [Next Right Now](https://github.com/UnlyEd/next-right-now), a **production-grade boilerplate** for the Next.js framework._ ## Online demo @@ -12,65 +21,116 @@ It is a single-page application (using a static page) that aims at showing an ** ## Features -It comes with the following features: +This RWA 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 automatically loaded upon page reload~~ - - Graph data (nodes, edges) are **persisted** in FaunaDB and automatically loaded upon page reload -- Real-time support for collaboration (open 2 tabs), using FaunaDB - - FaunaDB token is public and has read/update access rights on one table of the DB only - - All users share the same "Canvas" document in the DB - - This POC will **not improve further** the collaborative experience, it's only a POC (undo/redo undoes peer actions, undo/redo seems a bit broken sometimes) - -Known limitations: +- **Graph Editor** (Reaflow) + - Different kinds of node (`start`, `end`, `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 + - Native Reaflow Nodes, Edges and Ports are extended for reusability _(**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 visually, through the `BlockPickerMenu` component + - **Undo/redo** support (with shortcuts) + - Node/edge **deletion** + - Node **duplication** + - **Selection** of nodes and edges (one at a time) + - Automatically re-calculate the **height** of nodes when jumping lines in `textarea` + - _This is much harder than it might look like, because it triggers concurrent state updates that need to be [queued](./src/utils/canvasDatasetMutationsQueue.ts) so we don't lose part of the changes_ +- **Shared state manager** + - Uses **`Recoil`** + - It was my first time using Recoil, and I like it even more than I thought I would. It's very easy to use. + - The one thing that needs improvement are DevTools, it's not as powerful as other state manager have (Redux, MobX, etc.). + There are only few tools out there, and even fewer are compatible with Next.js. + - [recoil-devtools](https://github.com/ulises-jeremias/recoil-devtools) available (hit `(ctrl/cmd)+h`) +- Passwordless Authentication (Magic Link) + - Use Next.js API endpoint to authenticate the user securely + - Stores a `token` cookie that can only be read/written from the server side (`httpOnly`) + - Use `/api/login` endpoint that reads the token on the server side and returns its content, used by the frontend to know if the current user is authenticated +- **Real-time DB (FaunaDB)** + - Graph data _(nodes, edges, AKA `CanvasDataset`)_ are **persisted** in FaunaDB and automatically loaded upon page load + - Real-time stream for collaboration (open 2 tabs) + - When **not authenticated** (AKA "Guest"): + - FaunaDB token is public and has read/write access rights on one special shared document of the "Canvas" collection + - It cannot read/write anything else in the DB, it's completely safe + - All guests share the same "Canvas" document in the DB + - When **authenticated** (AKA "Editor"): + - A FaunaDB token is generated upon login and stored in the `token` cookie. This token is linked to the user and hold the **permissions** granted to the user. + Therefore, it will only allow what's configured in the FaunaDB "Editor" role. + - This RWA will **not improve further** the collaborative experience, it's only a POC (undo/redo undoes peer actions) +- Support for **Emotion 11** (CSS in JS) + +_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 -> This POC can be used as a boilerplate to start your own project using Reaflow. - ## Variants While working on this project, I've reached several milestones with a different set of features, available as "Examples": 1. [`with-local-storage`](https://github.com/Vadorequest/poc-nextjs-reaflow/tree/with-local-storage) ([Demo](https://poc-nextjs-reaflow-git-with-local-storage-ambroise-dhenain.vercel.app/) | [Diff](https://github.com/Vadorequest/poc-nextjs-reaflow/pull/14)): - The canvas dataset is stored in the browser localstorage. + The canvas dataset is stored in the browser localstorage. There is no real-time and no authentication. -1. [`with-faunadb-real-time`](https://github.com/Vadorequest/poc-nextjs-reaflow/tree/with-faunadb-real-time) - ([Demo](https://poc-nextjs-reaflow-git-with-faunadb-real-time-ambroise-dhenain.vercel.app/) | [Diff](https://github.com/Vadorequest/poc-nextjs-reaflow/pull/13)): - The canvas dataset is stored in FaunaDB. - Changes to the canvas are real-time and shared with everyone. +1. [`with-faunadb-real-time`](https://github.com/Vadorequest/poc-nextjs-reaflow/tree/with-faunadb-real-time) + ([Demo](https://poc-nextjs-reaflow-git-with-faunadb-real-time-ambroise-dhenain.vercel.app/) | [Diff](https://github.com/Vadorequest/poc-nextjs-reaflow/pull/13)): + The canvas dataset is stored in FaunaDB. + Changes to the canvas are real-time and shared with everyone. + Everybody shares the same working document. +1. [`with-magic-link-auth`](https://github.com/Vadorequest/poc-nextjs-reaflow/tree/with-magic-link-auth) + ([Demo](https://poc-nextjs-reaflow-git-with-magic-link-auth-ambroise-dhenain.vercel.app/) | [Diff](https://github.com/Vadorequest/poc-nextjs-reaflow/pull/15)): + The canvas dataset is stored in FaunaDB. + Changes to the canvas are real-time and shared with everyone. Everybody shares the same working document. + Users can create an account and login using Magic Link, but they still share the same Canvas document as guests. +1. [`with-faunadb-auth`](https://github.com/Vadorequest/poc-nextjs-reaflow/tree/with-faunadb-auth) + ([Demo](https://poc-nextjs-reaflow-git-with-faunadb-auth-ambroise-dhenain.vercel.app/) | [Diff](https://github.com/Vadorequest/poc-nextjs-reaflow/pull/12)): + The canvas dataset is stored in FaunaDB. + Changes to the canvas are real-time and shared with everyone when not authenticated. + Changes to the canvas are real-time and shared with yourself when being authenticated. (open 2 tabs to see it in action) + Users can create an account and login using Magic Link, they'll automatically load their own document. + +## Roadmap + +Here are the future variants I intend to work on: +- FaunaDB GraphQL (GQL): We currently use FQL to manipule the real-time stream (it's not compatible with GQL). + I'd like to use GQL for non real-time operations. + I'm thinking adding the add/edit/remove project features using GQL, to showcase usage of both FaunaDB FQL and GQL languages. +- FaunaDB IaC (Infrastructure as Code): Currently, the FaunaDB configuration is rather "simple", there are 2 tables, 1 index, 2 roles. + But it's not possible to generate the whole database configuration dynamically in an automated way. + I'd like to improve the DevOps experience and make it possible to deploy the whole thing in a new DB programmatically. + Also, I'd like to have proper function splits and unit testing to make the whole project (including roles, queries, indexes, etc.) automatically testable. + This would greatly increase the developer experience and confidence in our ability to duplicate the project to a new DB and creating different staging/production environments. + +External help on those features is much welcome! Please contribute ;) ## Getting started +> If you want to use this project to start your own, you can either clone it using git and run the below commands, or "Deploy your own" using the Vercel button, which will create for you the Vercel and GitHub project (but won't configure environment variables for you!). + - `yarn` - `yarn start` +- Run commands in `fql/setup.js` from the Web Shell at [https://dashboard.fauna.com/](https://dashboard.fauna.com/), this will create the FaunaDB collection, indexes, roles, etc. - `cp .env.local.example .env.local`, and define your environment variables - Open browser at [http://localhost:8890](http://localhost:8890) +If you deploy it to Vercel, you'll need to create Vercel environment variables for your project. (see `.env.local.example` file) + ## Deploy your own Deploy the example using [Vercel](https://vercel.com): [![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) -## Advanced - ELK +--- -ELKjs (and ELK) are used to draw the graph (nodes, edges). +# Advanced + +This section is for developers who want to understand even deeper how things work. + +## Reaflow Graph (ELK) + +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**. @@ -78,7 +138,7 @@ It seems to be one of the best Layout manager out there. Unfortunately, it is quite complicated and lacks a comprehensive documentation. -You'll need to dig into the ELK documentation and issues if you're trying to change **how the graph's layout behaves**. +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. - [ELKjs GitHub](https://github.com/kieler/elkjs) @@ -94,3 +154,78 @@ Here are some good places to start and useful links I've compiled for my own sak Known limitations: - [Tracking issue - Manually positioning the nodes ("Standalone Edge Routing")](https://github.com/eclipse/elk/issues/315) + +--- + +# Inspirations + +Here is a list of online resources and open-source repositories that have been the most helpful: + +**Understanding FaunaDB:** +- https://fauna.com/blog/modernizing-from-postgresql-to-serverless-with-fauna-part-1 + +**Authentication and authorization:** +- https://docs.fauna.com/fauna/current/tutorials/basics/authentication?lang=javascript +- https://magic.link/posts/todomvc-magic-nextjs-fauna (tuto Magic + Next.js + FaunaDB) + - https://github.com/magiclabs/example-nextjs-faunadb-todomvc (repo) + +**Real-time streaming:** +- https://github.com/fauna-brecht/fauna-streaming-example Very different from what is built here, but holds solid foundations about streaming + - https://github.com/fauna-brecht/fauna-streaming-example/blob/776c911eb4/src/data/streams.js + +**FaunaDB Real-world apps (RWA):** +- https://docs.fauna.com/fauna/current/start/apps/fwitter +- https://github.com/fauna-brecht/skeleton-auth +- https://github.com/fillipvt/with-graphql-faunadb-cookie-auth +- https://github.com/fauna-brecht/fauna-streaming-example +- https://github.com/magiclabs/example-nextjs-faunadb-todomvc + +**FaunaDB FQL:** +- UDF + - https://docs.fauna.com/fauna/current/security/roles API definitions for CRUD ops +- https://github.com/shiftx/faunadb-fql-lib +- https://docs.fauna.com/fauna/current/cookbook/?lang=javascript +- https://github.com/fauna-brecht/faunadb-auth-skeleton-frontend/blob/default/fauna-queries/helpers/fql.js + +**FaunaDB GQL:** +- https://css-tricks.com/instant-graphql-backend-using-faunadb/ +- https://github.com/ptpaterson/faunadb-graphql-schema-loader +- https://github.com/Plazide/fauna-gql-upload +- Schema management + - https://github.com/fillipvt/with-graphql-faunadb-cookie-auth/blob/master/scripts/uploadSchema.js + +**FaunaDB DevOps:** +- https://github.com/fauna-brecht/fauna-schema-migrate + +**FaunaDB Community resources:** +- https://github.com/n400/awesome-faunadb + - https://gist.github.com/BrunoQuaresma/0236aff64dc44795f19994cbc7a07db6 React query hook + - https://gist.github.com/tovbinm/f76bcbf56ea8e2e3740e237b6c2f2ab9 GraphQL relation query examples + - https://gist.github.com/TracyNgot/291738b403cfa012fe7bf05614c22408 Query builder + +--- + +# Real-time implementation, limits, and considerations for the future + +The way the current real-time feature is implemented is not too bad, but not great either. + +It works by syncing the whole dataset whether the remote `document` (on FaunaDB) is updated, which in turn updates all subscribed clients (except the author). +While this works, changes from one client can be overwritten by another client when they happen at the same time. + +> `document` means "Canvas Dataset" here. It contains all `nodes` and `edges` (and other props, like `owner`, etc.) + +A better implementation would be not to stream the actual `document`, but only the document's **patches**. +The whole `document` would only be useful for the initialization of the app. +Then, any change should be streamed to another document which would only contain the changes applied to the initial document. +When such changes are streamed (patches), they should then be applied to the current working document, one by one, in order. + +Each change/patch would represent a diff between the previous and after states of the document, they would only contain **what** have changed: +- A node has been added +- An edge has been deleted +- An edge has been modified + +This way, when something changes, the client would resolve what's changed and stream the patch to the DB, which in turn would update all subscribed clients which would apply that patch. + +Conflict may still arise, but they'll be limited to parts of the document that have been updated simultaneously (the same node, the same edge, etc.). + +This would provide a much better user experience, because overwrites will happen much less often, and it'd increase collaboration. diff --git a/fql/examples.js b/fql/examples.js new file mode 100644 index 0000000..f2be4ed --- /dev/null +++ b/fql/examples.js @@ -0,0 +1,15 @@ +Create( + Collection('Canvas'), + { + data: { + owner: Ref(Collection("Users"), "292674252603130373"), + nodes: [], + edges: [], + } + }, +) + +Lambda("ref", Equals( + CurrentIdentity(), + Select(["data", "owner"], Get(Var("ref"))) +)) diff --git a/fql/setup.js b/fql/setup.js new file mode 100644 index 0000000..6765252 --- /dev/null +++ b/fql/setup.js @@ -0,0 +1,165 @@ +// ---------------------- Step 1: Create a "Users" collection ---------------------- +CreateCollection({ name: 'Users' }); + +// ---------------------- Step 2: Create "Canvas" collection ---------------------- +CreateCollection({ name: 'Canvas' }); + +// ---------------------- Step 3: Create indexes ---------------------- +// Index to filter users by email +// Necessary for authentication, to find the user document based on their email +CreateIndex({ + name: 'usersByEmail', + source: Collection('Users'), + terms: [ + { field: ['data', 'email'] }, + ], + unique: true, +}); + +// Index to filter canvas by owner +// Necessary for real-time subscription, to retrieve the canvas of the current user +CreateIndex({ + name: 'canvasByOwner', + source: Collection('Canvas'), + // Needs permission to read the Users, because "owner" is specified in the "terms" and is a Ref to the "Users" collection + permissions: { + read: Collection('Users'), + }, + // Allow to filter by owner ("Users") + terms: [ + { field: ['data', 'owner'] }, + ], + // Index contains the Canvas ref (that's the default behavior and could be omitted) + values: [ + { field: ['ref'] }, + ], +}); + +// ---------------------- Step 4: Create roles ---------------------- + +// The "Editor" role is assigned to all authenticated users +// It is automatically assigned when a user is authenticated, because it defines "membership" to the Users collection +// It is secure because the token is generated upon login on the server-side and stored in a "httpOnly" cookie that can only be read/written on the server-side +// The token is specific to the user and is used on the frontend +// The token only allows the user to read/write documents that belongs to him +CreateRole({ + name: 'Editor', + // All users should be editors (will apply to authenticated users only). + membership: [ + { + resource: Collection('Users'), + }, + ], + privileges: [ + { + // Editors need read access to the canvasByOwner index to find their own canvas + resource: Index('canvasByOwner'), + actions: { + read: true, + }, + }, + { + resource: Collection('Canvas'), + actions: { + // Editors should be able to read (+ history) of Canvas documents that belongs to them. + read: Query( + Lambda('ref', Equals( + CurrentIdentity(), + Select(['data', 'owner'], Get(Var('ref'))), + )), + ), + history_read: Query( + Lambda('ref', Equals( + CurrentIdentity(), + Select(['data', 'owner'], Get(Var('ref'))), + )), + ), + // Editors should be able to edit only Canvas documents that belongs to them + write: Query( + Lambda( + ['oldData', 'newData', 'ref'], + And( + // The owner in the current data (before writing them) must be the current user + Equals( + CurrentIdentity(), + Select(['data', 'owner'], Var('oldData')), + ), + // The owner must not change + Equals( + Select(['data', 'owner'], Var('oldData')), + Select(['data', 'owner'], Var('newData')), + ), + ), + ), + ), + // Editors should be able to create only Canvas documents that belongs to them + create: Query( + Lambda('values', Equals( + CurrentIdentity(), + Select(['data', 'owner'], Var('values'))), + ), + ), + }, + }, + ], +}); + +// The "Public" role is assigned to anyone who isn't authenticated +// It doesn't use "membership" (unlike "Editor" role) but a token created manually that doesn't expire +// It is secure because the token only grant access to the special document of id "1", which is shared amongst all guests +// Guests can only read/write this particular document and not any other +CreateRole({ + name: 'Public', + // The public role is meant to be used to generate a token which allows anyone (unauthenticated users) to update the canvas + membership: [], + privileges: [ + { + resource: Collection('Canvas'), + actions: { + // Guests should only be allowed to read the Canvas of id "1" + read: Query( + Lambda('ref', + Equals( + '1', + Select(['id'], Var('ref'), + ), + ), + ), + ), + // Guests should only be allowed to update the Canvas of id "1" + write: Query( + Lambda( + ['oldData', 'newData', 'ref'], + Equals( + '1', + Select(['id'], Var('ref')), + ), + ), + ), + // Guests should only be allowed to create the Canvas of id "1" + create: Query( + Lambda('values', + Equals( + '1', + Select(['ref', 'id'], Var('values')), + ), + ), + ), + // Creating a record with a custom ID requires history_write privilege + // See https://fauna-community.slack.com/archives/CAKNYCHCM/p1615413941454700 + history_write: Query( + Lambda( + ['ref', 'ts', 'action', 'data'], + Equals( + '1', + Select(['id'], Var('ref')), + ), + ), + ), + }, + }, + ], +}); + +// Create the shared Canvas record +Create(Ref(Collection('Canvas'), '1')); diff --git a/package.json b/package.json index 4d84ee8..dd4a41f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "build": "next build", "start": "next dev --port 8890", "type-check": "tsc", - "link:reaflow": "yarn link reaflow && yarn link react && yarn link react-dom" + "link:reaflow": "yarn link reaflow && yarn link react && yarn link react-dom", + "deploy:fake": "git commit --allow-empty -m \"Fake empty commit (force CI trigger)\"" }, "dependencies": { "@chakra-ui/icons": "1.0.4", @@ -20,6 +21,7 @@ "@hapi/iron": "6.0.0", "@magic-sdk/admin": "1.3.0", "@types/lodash.clonedeep": "4.5.6", + "@types/lodash.isempty": "4.4.6", "@types/lodash.merge": "4.6.6", "@types/lodash.now": "4.0.6", "@types/lodash.size": "4.2.6", @@ -28,6 +30,7 @@ "animate.css": "4.1.1", "classnames": "2.2.6", "cookie": "0.4.1", + "deep-object-diff": "1.1.0", "faunadb": "4.1.1", "faunadb-fql-lib": "0.13.0", "framer-motion": "3.2.1", @@ -36,7 +39,9 @@ "lodash.debounce": "4.0.8", "lodash.filter": "4.6.0", "lodash.includes": "4.3.0", + "lodash.isempty": "4.4.0", "lodash.isequal": "4.5.0", + "lodash.isobjectlike": "4.0.0", "lodash.merge": "4.6.2", "lodash.now": "4.0.2", "lodash.remove": "4.7.0", @@ -57,6 +62,7 @@ "recoil-devtools-log-monitor": "^0.2.7", "recoil-devtools-logger": "^0.1.5", "swr": "0.4.2", + "use-debounce": "6.0.0", "uuid": "8.3.2" }, "devDependencies": { @@ -68,6 +74,7 @@ "@types/lodash.filter": "4.6.6", "@types/lodash.includes": "4.3.6", "@types/lodash.isequal": "4.5.5", + "@types/lodash.isobjectlike": "4.0.6", "@types/lodash.remove": "4.7.6", "@types/lodash.some": "4.6.6", "@types/lodash.sortby": "4.7.6", diff --git a/src/components/Animated3Dots.tsx b/src/components/Animated3Dots.tsx new file mode 100644 index 0000000..5367111 --- /dev/null +++ b/src/components/Animated3Dots.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +export type Props = { + /** + * Color of the dots. + * + * @default white + */ + fill?: string; +}; + +/** + * An animated composant featuring 3 animated dots "...". + * + * Each dot is animated separately, in alternation. + * Requires animate.css library. + * + * @see https://animate.style + */ +const Animated3Dots = (props: Props): JSX.Element => { + return ( + + + + + + ); +}; + +export default Animated3Dots; diff --git a/src/components/AuthFormModal.tsx b/src/components/AuthFormModal.tsx index 9cb6bce..08f8338 100644 --- a/src/components/AuthFormModal.tsx +++ b/src/components/AuthFormModal.tsx @@ -10,13 +10,12 @@ import { ModalOverlay, useDisclosure, } from '@chakra-ui/react'; -import { Magic } from 'magic-sdk'; -import Router from 'next/router'; import React, { useEffect, useState, } from 'react'; import { magicClient } from '../lib/auth/magicClient'; +import Animated3Dots from './Animated3Dots'; type Props = { mode: 'login' | 'create-account'; @@ -24,19 +23,34 @@ type Props = { const LS_EMAIL_KEY = 'email'; +/** + * Login/Signup form. + */ const AuthFormModal = (props: Props) => { const { mode } = props; const isLoginForm = mode === 'login'; const { isOpen, onOpen, onClose } = useDisclosure(); const [email, setEmail] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); /** + * Submits the form. + * + * Fetches a DID token from Magic API. + * Fetches our internal /api/login endpoint which creates the authentication cookie. + * Once the cookie is set, the "useUserSession" hook will return the currently authenticated user. + * + * The same function is used for both account creation and login. + * There are 2 buttons but it's only visual, there is no difference between both, they both call the same /api/login endpoint which handles both. * * @param event + * + * @see https://docs.magic.link/decentralized-id#what-is-a-did-token What is a DID token? * @see https://docs.magic.link/client-sdk/web/api-reference#loginwithmagiclink */ const onSubmit = async (event: MouseEvent): Promise => { event.preventDefault(); + setIsSubmitting(true); try { localStorage?.setItem(LS_EMAIL_KEY, email); @@ -46,7 +60,7 @@ const AuthFormModal = (props: Props) => { email: email, showUI: true, }); - console.info('User has logged in') + console.info('User has logged in'); const res = await fetch('/api/login', { method: 'POST', headers: { @@ -57,8 +71,8 @@ const AuthFormModal = (props: Props) => { }); if (res.status === 200) { - onClose(); - Router.push('/'); // Forces a re-render + // The user is now authenticated (cookie has been set on the browser) to both Magic and FaunaDB + onClose(); // XXX Updating the state here has a side-effect, it'll automatically refresh the UI, which will update and display user-related informations } else { throw new Error(await res.text()); } @@ -70,6 +84,9 @@ const AuthFormModal = (props: Props) => { } }; + /** + * Automatically loads the previous email used from localstorage. + */ useEffect(() => { try { setEmail(localStorage?.getItem(LS_EMAIL_KEY) || ''); @@ -78,8 +95,15 @@ const AuthFormModal = (props: Props) => { } }, [mode]); + /** + * Preloads the static assets required to render the Magic iframe context. + * + * Makes the iframe display faster the first time the user loads it (better UX). + * + * @see https://docs.magic.link/client-sdk/web/api-reference#preload + */ useEffect(() => { - magicClient.preload(); // See https://docs.magic.link/client-sdk/web/api-reference#preload + magicClient.preload(); }, []); return ( @@ -113,19 +137,29 @@ const AuthFormModal = (props: Props) => { - + { + !isSubmitting && ( + + ) + } + diff --git a/src/components/FaunaDBCanvasStream.tsx b/src/components/FaunaDBCanvasStream.tsx new file mode 100644 index 0000000..7b2266c --- /dev/null +++ b/src/components/FaunaDBCanvasStream.tsx @@ -0,0 +1,145 @@ +import { useToast } from '@chakra-ui/react'; +import { css } from '@emotion/react'; +import { isBrowser } from '@unly/utils'; +import { Subscription } from 'faunadb/src/types/Stream'; +import now from 'lodash.now'; +import React, { + Dispatch, + SetStateAction, + useState, +} from 'react'; +import { useDebouncedEffect } from '../hooks/useDebouncedEffect'; +import { useUserSession } from '../hooks/useUserSession'; +import { + OnStreamedDocumentUpdate, + OnStreamError, + OnStreamInit, + OnStreamStart, +} from '../types/faunadb/CanvasStream'; +import { FaunaError } from '../types/faunadb/FaunaError'; +import { TypeOfRef } from '../types/faunadb/TypeOfRef'; +import { initStream } from '../utils/canvasStream'; + +type Props = { + onInit: OnStreamInit; + onUpdate: OnStreamedDocumentUpdate; + setCanvasDocRef: Dispatch>; +} + +/** + * Handles the FaunaDB stream to the Canvas document. + * + * Handles lifecycle (init/close). + */ +const FaunaDBCanvasStream: React.FunctionComponent = (props) => { + const { + onInit, + onUpdate, + setCanvasDocRef, + } = props; + + // Used to avoid starting several streams from the same browser + const [hasStreamStarted, setHasStreamStarted] = useState(false); + const [stream, setStream] = useState(undefined); + const [canvasRef, setCanvasRef] = useState(undefined); + const [startedAt, setStartedAt] = useState(undefined); + const toast = useToast({ + position: 'bottom-right', + duration: 10000, + isClosable: true, + status: 'error', + }); + const user = useUserSession(); + const errors: { at: number, error: Error }[] = []; + + if (!isBrowser()) { + return null; + } + + /** + * Triggered when the stream has started. + * + * @param stream + * @param canvasRef + * @param at + */ + const onStreamStarted: OnStreamStart = (stream: Subscription, canvasRef: TypeOfRef, at: number) => { + setStream(stream); + setCanvasRef(canvasRef); + setCanvasDocRef(canvasRef); + setStartedAt(at); + }; + + /** + * Stream error handling. + * + * @param error + * @param restartStream + */ + const onStreamError: OnStreamError = (error: FaunaError, restartStream) => { + errors.push({ at: now(), error }); + + // Display a toast for the end-user to understand something's wrong + toast({ + title: `Streaming error - "${error?.name}"`, + description: `${error?.description} (${error?.message})`, + }); + + // Protect against too many errors on the client + // TODO This should be improved to consider only recent errors (last minute?) to avoid stopping long-running session by mistake + if (errors?.length > 100) { + console.error('Too many errors, real-time stream has been stopped.', errors); + } else { + if (error?.name === 'PermissionDenied') { + // No permission, this isn't supposed to happen in our app + console.error('Permission error'); + setTimeout(restartStream, 10000); + } else { + setTimeout(restartStream, 2000); + } + } + }; + + /** + * Handles stream subscription. + * + * Handles stream initialization and changes when the user logs in and logs out. + * Updates when the user changes. + * + * Debounced to avoid creating too many streams in a loop when things go wrong. + */ + useDebouncedEffect(() => { + console.log('FaunaDBCanvasStream useEffect', hasStreamStarted, user); + if (!hasStreamStarted) { + // If the stream hasn't started yet, it means it's the first time the stream is opened for this browser page (there were no stream opened previously) + setHasStreamStarted(true); + + initStream(user, onStreamStarted, onInit, onUpdate, onStreamError); + } else { + console.log('Closing stream.'); + // If the stream was already started, then it means the user has changed (logged in, or logged out) + // In such case, we unsubscribe to the stream and restart it + stream?.close(); + + initStream(user, onStreamStarted, onInit, onUpdate, onStreamError); + } + }, 1000, [user?.id]); + + // Display meta information about the current document, helps debugging/understanding which document is being updated + return ( +
+ {/* XXX I probably messed something up with FQL, when logged in, the "canvasRef" type is "Ref", but it's "Expr" otherwise */} + Working on doc N°{canvasRef?.id || (canvasRef as any)?.raw?.id} +
+ ); +}; + +export default FaunaDBCanvasStream; diff --git a/src/components/GlobalStyles.tsx b/src/components/GlobalStyles.tsx new file mode 100644 index 0000000..d38ce51 --- /dev/null +++ b/src/components/GlobalStyles.tsx @@ -0,0 +1,77 @@ +import { + css, + Global, +} from '@emotion/react'; +import React from 'react'; + +type Props = {} + +/** + * Those styles are applied + * - universally (browser + server) + * - globally (applied to all pages), through _app + * + * @param props + */ +const GlobalStyles: React.FunctionComponent = (props): JSX.Element => { + + return ( + + ); +}; + +export default GlobalStyles; diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index c31edad..cf2b22b 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -10,12 +10,12 @@ import { css } from '@emotion/react'; import React, { Fragment } from 'react'; import settings from '../settings'; import AuthFormModal from './AuthFormModal'; -import { useUser } from './hooks/useUser'; +import { useUserSession } from '../hooks/useUserSession'; type Props = {} const Nav: React.FunctionComponent = (props) => { - const user = useUser(); + const user = useUserSession(); return (
= (props) => { { - user ? ( + user?.isAuthenticated ? ( Welcome {user?.email}! You are working on a shared document, even though you have your own account. @@ -50,7 +50,7 @@ const Nav: React.FunctionComponent = (props) => { { - user ? ( + user?.isAuthenticated ? ( diff --git a/src/components/RecoilDevtools.tsx b/src/components/RecoilDevtools.tsx index a634686..7e98d19 100644 --- a/src/components/RecoilDevtools.tsx +++ b/src/components/RecoilDevtools.tsx @@ -20,8 +20,8 @@ export const RecoilDevtools = () => { diff --git a/src/components/blocks/BlockPickerMenu.tsx b/src/components/blocks/BlockPickerMenu.tsx index 73a23cb..4514664 100644 --- a/src/components/blocks/BlockPickerMenu.tsx +++ b/src/components/blocks/BlockPickerMenu.tsx @@ -82,8 +82,8 @@ const BlockPickerMenu: React.FunctionComponent = (props) => { top: ${typeof top !== 'undefined' ? `${top}px` : `initial`}; bottom: ${typeof top !== 'undefined' ? `initial` : `0`}; left: ${typeof left !== 'undefined' ? `${left}px` : `calc(50% - 100px)`}; - width: 200px; - height: 50px; + width: 300px; + height: 58px; background-color: white; border-radius: 5px; padding: 10px; @@ -92,12 +92,6 @@ const BlockPickerMenu: React.FunctionComponent = (props) => { display: flex; flex-wrap: nowrap; justify-content: space-evenly; - - div { - padding: 5px; - border: 1px solid; - cursor: pointer; - } } `} > diff --git a/src/components/blocks/IfBlock.tsx b/src/components/blocks/IfBlock.tsx index 726d401..58f4820 100644 --- a/src/components/blocks/IfBlock.tsx +++ b/src/components/blocks/IfBlock.tsx @@ -1,6 +1,7 @@ import React from 'react'; import BaseBlockComponent from '../../types/BaseBlockComponent'; import { OnBlockClick } from '../../types/BlockPickerMenu'; +import { Button } from '@chakra-ui/button'; type Props = { onBlockClick: OnBlockClick; @@ -19,11 +20,12 @@ const IfBlock: BaseBlockComponent = (props) => { }; return ( -
If/Else -
+ ); }; diff --git a/src/components/blocks/InformationBlock.tsx b/src/components/blocks/InformationBlock.tsx index 027a098..13f1b76 100644 --- a/src/components/blocks/InformationBlock.tsx +++ b/src/components/blocks/InformationBlock.tsx @@ -1,6 +1,7 @@ import React from 'react'; import BaseBlockComponent from '../../types/BaseBlockComponent'; import { OnBlockClick } from '../../types/BlockPickerMenu'; +import { Button } from '@chakra-ui/button'; type Props = { onBlockClick: OnBlockClick; @@ -19,11 +20,12 @@ const InformationBlock: BaseBlockComponent = (props) => { }; return ( -
Information -
+ ); }; diff --git a/src/components/blocks/QuestionBlock.tsx b/src/components/blocks/QuestionBlock.tsx index 9a8cf89..a18c9e8 100644 --- a/src/components/blocks/QuestionBlock.tsx +++ b/src/components/blocks/QuestionBlock.tsx @@ -1,6 +1,7 @@ import React from 'react'; import BaseBlockComponent from '../../types/BaseBlockComponent'; import { OnBlockClick } from '../../types/BlockPickerMenu'; +import { Button } from '@chakra-ui/button'; type Props = { onBlockClick: OnBlockClick; @@ -19,11 +20,12 @@ const QuestionBlock: BaseBlockComponent = (props) => { }; return ( -
Question -
+ ); }; diff --git a/src/components/edges/AbsoluteLabelEditor.tsx b/src/components/edges/AbsoluteLabelEditor.tsx new file mode 100644 index 0000000..fea625d --- /dev/null +++ b/src/components/edges/AbsoluteLabelEditor.tsx @@ -0,0 +1,121 @@ +import { Button, Input } from '@chakra-ui/react'; +import { css } from '@emotion/react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { + FC, + useEffect, + useState, +} from 'react'; +import { useRecoilState } from 'recoil'; +import { absoluteLabelEditorState } from '../../states/absoluteLabelEditorStateState'; +import useFocus from '../../hooks/useFocus'; + +type Props = {} + +/** + * Label editor displayed in absolute position. + * + * Displays on top of the canvas. + */ +const AbsoluteLabelEditor: FC = (props) => { + const [absoluteLabelEditor, setAbsoluteLabelEditor] = useRecoilState(absoluteLabelEditorState); + const { + isDisplayed, + x, + y, + defaultValue, + onSubmit, + } = absoluteLabelEditor || {}; + const [label, setLabel] = useState(''); + const [inputRef, setInputFocus] = useFocus(); + + /** + * Apply the defaultValue as value when it changes. + * + * Used to pre-load the current value of the input. + */ + useEffect(() => { + setLabel(defaultValue?.trim() || ''); + }, [defaultValue]); + + /** + * Always focus on the input when any state change is made. + * + * Forces re-focus when going from one label editor to another. + */ + useEffect(() => { + setInputFocus(); + }); + + if (!isDisplayed) { + return null; + } + + /** + * When the content of the input changes. + * + * @param event + */ + const onInputChange = (event: any) => { + setLabel(event.target.value); + }; + + /** + * When pressing "Enter", automatically clicks on the submit button. + * + * @param event + */ + const onInputKeyDown = (event: any) => { + if(event?.code === 'Enter'){ + onIconClick(); + } + } + + /** + * The icon acts as submit button. + */ + const onIconClick = () => { + onSubmit?.(label); + + setAbsoluteLabelEditor({ + isDisplayed: false, + }); + }; + + return ( +
+ + +
+ ); +}; + +export default AbsoluteLabelEditor; diff --git a/src/components/edges/BaseEdge.tsx b/src/components/edges/BaseEdge.tsx index 244893f..17a9e0f 100644 --- a/src/components/edges/BaseEdge.tsx +++ b/src/components/edges/BaseEdge.tsx @@ -13,6 +13,7 @@ import { useRecoilState, useSetRecoilState, } from 'recoil'; +import { absoluteLabelEditorState } from '../../states/absoluteLabelEditorStateState'; import { blockPickerMenuSelector } from '../../states/blockPickerMenuState'; import { canvasDatasetSelector } from '../../states/canvasDatasetSelector'; import { edgesSelector } from '../../states/edgesState'; @@ -20,11 +21,11 @@ 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 BaseEdgeProps, { PatchCurrentEdge } 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 { NewCanvasDatasetMutation } from '../../types/CanvasDatasetMutation'; import { LastCreated } from '../../types/LastCreated'; import NodeType from '../../types/NodeType'; import { translateXYToCanvasPosition } from '../../utils/canvas'; @@ -33,6 +34,7 @@ import { getDefaultNodePropsWithFallback, upsertNodeThroughPorts, } from '../../utils/nodes'; +import Label from './Label'; type Props = {} & BaseEdgeProps; @@ -53,7 +55,9 @@ const BaseEdge: React.FunctionComponent = (props) => { sourcePort: sourcePortId, target: targetNodeId, targetPort: targetPortId, + queueCanvasDatasetMutation, } = props; + // console.log('props', props) const [blockPickerMenu, setBlockPickerMenu] = useRecoilState(blockPickerMenuSelector); const [canvasDataset, setCanvasDataset] = useRecoilState(canvasDatasetSelector); @@ -64,6 +68,7 @@ const BaseEdge: React.FunctionComponent = (props) => { const edge: BaseEdgeData = edges.find((edge: BaseEdgeData) => edge?.id === id) as BaseEdgeData; const [selectedEdges, setSelectedEdges] = useRecoilState(selectedEdgesSelector); const [selectedNodes, setSelectedNodes] = useRecoilState(selectedNodesSelector); + const setAbsoluteLabelEditor = useSetRecoilState(absoluteLabelEditorState); if (typeof edge === 'undefined') { return null; @@ -90,16 +95,24 @@ const BaseEdge: React.FunctionComponent = (props) => { * @param event */ const onAddIconClick = (event: React.MouseEvent): void => { - console.log('onAdd edge', edge, event); + /** + * Executed when clicking on a block. + * Creates a new node corresponding to the selected block. + * + * @param nodeType + */ const onBlockClick: OnBlockClick = (nodeType: NodeType) => { - console.log('onBlockClick (from edge add)', nodeType, edge); + console.groupCollapsed('Clicked on block from edge, upserting new node'); const newNode: BaseNodeData = createNodeFromDefaultProps(getDefaultNodePropsWithFallback(nodeType)); - const newDataset: CanvasDataset = upsertNodeThroughPorts(cloneDeep(nodes), cloneDeep(edges), edge, newNode); + const mutations: NewCanvasDatasetMutation[] = upsertNodeThroughPorts(cloneDeep(nodes), cloneDeep(edges), edge, newNode); + + // Apply all mutations + mutations.map((mutation) => queueCanvasDatasetMutation(mutation)); - setCanvasDataset(newDataset); setLastCreatedNode({ node: newNode, at: now() }); setSelectedNodes([newNode?.id]); setSelectedEdges([]); + console.groupEnd(); }; // 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 @@ -125,7 +138,14 @@ const BaseEdge: React.FunctionComponent = (props) => { */ const onRemoveIconClick = (event: React.MouseEvent): void => { console.log('onRemoveIconClick', event, edge); - setEdges(edges.filter((edge: BaseEdgeData) => edge.id !== id)); + const mutation: NewCanvasDatasetMutation = { + operationType: 'delete', + elementId: edge?.id, + elementType: 'edge', + }; + + console.log('Adding edge patch to the queue', 'mutation:', mutation); + queueCanvasDatasetMutation(mutation); }; /** @@ -142,9 +162,31 @@ const BaseEdge: React.FunctionComponent = (props) => { setSelectedEdges([edge.id]); }; + /** + * Patches the properties of the current edge. + * + * Only updates the provided properties, doesn't update other properties. + * Will use deep merge of properties. + * + * @param patch + * @param stateUpdateDelay + */ + const patchCurrentEdge: PatchCurrentEdge = (patch: Partial, stateUpdateDelay = 0): void => { + const mutation: NewCanvasDatasetMutation = { + operationType: 'patch', + elementId: edge?.id, + elementType: 'edge', + changes: patch, + }; + + console.log('Adding edge patch to the queue', 'patch:', patch, 'mutation:', mutation); + queueCanvasDatasetMutation(mutation, stateUpdateDelay); + }; + return ( } className={classnames(`edge-svg-graph`, { 'is-selected': isSelected })} onClick={onEdgeClick} > @@ -158,6 +200,29 @@ const BaseEdge: React.FunctionComponent = (props) => { const x = (center?.x || 0) - 25; const y = (center?.y || 0) - 25; + /** + * Triggered when the label has been modified. + * + * @param value + */ + const onLabelSubmit = (value: string) => { + console.log('value', value); + + patchCurrentEdge({ + text: value || ' ', // Use a space as default, to increase the distance between nodes, which ease edge's selection + }); + }; + + const onStartLabelEditing = (event: React.MouseEvent) => { + setAbsoluteLabelEditor({ + x: window.innerWidth / 2, + y: 0, + defaultValue: edge?.text, + onSubmit: onLabelSubmit, + isDisplayed: true, + }); + }; + return ( = (props) => { color: black; z-index: 1; + // Disabling pointer-events on top-level container, because the foreignObject is displayed on top (above) the edge line itself and blocks selection + pointer-events: none; + .edge { // XXX Elements within a that are using the CSS "position" attribute won't be shown properly, // unless they're wrapped into a container using a "fixed" position. // Solves the display of React Select element. // See https://github.com/chakra-ui/chakra-ui/issues/3288#issuecomment-776316200 position: fixed; + + // Enable pointer events for elements within the edge + pointer-events: auto; } .svg-inline--fa { cursor: pointer; + margin: 4px; } `} > @@ -190,18 +262,20 @@ const BaseEdge: React.FunctionComponent = (props) => { isSelected && (
) diff --git a/src/components/edges/Label.tsx b/src/components/edges/Label.tsx new file mode 100644 index 0000000..6c8bf43 --- /dev/null +++ b/src/components/edges/Label.tsx @@ -0,0 +1,44 @@ +import { css } from '@emotion/react'; +import classNames from 'classnames'; +import React, { FC } from 'react'; +import { LabelProps } from 'reaflow'; + +const Label: FC> = (props) => { + const { + text, + x, + y, + style, + className, + originalText, + } = props; + + const offsetY = -20; // Make label displays below the edge line + // Dynamically resolve a right offset based on the size of the text to display (moves the text to the left to center it) + let offsetX = (text?.length || 0) * 2; // Padding left 2px per character + + if (offsetX > 50) { + offsetX = 50; // Mustn't be higher than 50, otherwise it'll be floating above another node + } + + return ( + + {originalText} + + {text} + + + ); +}; + +export default Label; diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index e35dbd8..9180788 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -16,15 +16,30 @@ import { useUndo, } from 'reaflow'; import { useRecoilState } from 'recoil'; -import { updateSharedCanvasDocument } from '../../lib/faunadb/faunadbClient'; +import { useDebouncedCallback } from 'use-debounce'; +import { v1 as uuid } from 'uuid'; +import { usePreviousValue } from '../../hooks/usePreviousValue'; +import useRenderingTrace from '../../hooks/useTraceUpdate'; +import { useUserSession } from '../../hooks/useUserSession'; import settings from '../../settings'; import { blockPickerMenuSelector } from '../../states/blockPickerMenuState'; import { canvasDatasetSelector } from '../../states/canvasDatasetSelector'; -import { edgesSelector } from '../../states/edgesState'; -import { nodesSelector } from '../../states/nodesState'; import { selectedEdgesSelector } from '../../states/selectedEdgesState'; import { selectedNodesSelector } from '../../states/selectedNodesState'; +import { UserSession } from '../../types/auth/UserSession'; import BaseNodeData from '../../types/BaseNodeData'; +import { CanvasDataset } from '../../types/CanvasDataset'; +import { QueueCanvasDatasetMutation } from '../../types/CanvasDatasetMutation'; +import { TypeOfRef } from '../../types/faunadb/TypeOfRef'; +import { + applyPendingMutations, + mutationsQueue, +} from '../../utils/canvasDatasetMutationsQueue'; +import { + onInit, + onUpdate, + updateUserCanvas, +} from '../../utils/canvasStream'; import { isOlderThan } from '../../utils/date'; import { createEdge } from '../../utils/edges'; import { @@ -37,6 +52,7 @@ import { } from '../../utils/ports'; import canvasUtilsContext from '../context/canvasUtilsContext'; import BaseEdge from '../edges/BaseEdge'; +import FaunaDBCanvasStream from '../FaunaDBCanvasStream'; import NodeRouter from '../nodes/NodeRouter'; type Props = { @@ -63,6 +79,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n const { canvasRef, } = props; + const userSession = useUserSession(); /** * The canvas ref contains useful properties (xy, scroll, etc.) and functions (zoom, centerCanvas, etc.) @@ -70,28 +87,103 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n * @see https://reaflow.dev/?path=/story/docs-advanced-refs--page */ useEffect(() => { - console.log('canvasRef', canvasRef); canvasRef?.current?.fitCanvas?.(); }, [canvasRef]); const [blockPickerMenu, setBlockPickerMenu] = useRecoilState(blockPickerMenuSelector); const [canvasDataset, setCanvasDataset] = useRecoilState(canvasDatasetSelector); - const [nodes, setNodes] = useRecoilState(nodesSelector); - const [edges, setEdges] = useRecoilState(edgesSelector); const [selectedNodes, setSelectedNodes] = useRecoilState(selectedNodesSelector); const [selectedEdges, setSelectedEdges] = useRecoilState(selectedEdgesSelector); const selections = selectedNodes; // TODO merge selected nodes and edges const [hasClearedUndoHistory, setHasClearedUndoHistory] = useState(false); const [cursorXY, setCursorXY] = useState<[number, number]>([0, 0]); + const [isStreaming, setIsStreaming] = useState(false); + const [canvasDocRef, setCanvasDocRef] = useState(undefined); // We store the document ref to avoid fetching it for every change + const [mutationsCounter, setMutationsCounter] = useState(0); + useRenderingTrace('CanvasContainer', { + ...props, + blockPickerMenu, + canvasDataset, + selectedNodes, + selectedEdges, + hasClearedUndoHistory, + cursorXY, + isStreaming, + canvasDocRef, + mutationsCounter, + }); + const nodes = canvasDataset?.nodes; + const edges = canvasDataset?.edges; + const previousCanvasDataset: CanvasDataset | undefined = usePreviousValue(canvasDataset); + + /** + * Applies all mutations that haven't been applied yet. + */ + useEffect(() => { + applyPendingMutations({ nodes, edges, mutationsCounter, setCanvasDataset }); + }); + + /** + * Adds a new patch to apply to the existing queue. + * + * @param patch + * @param stateUpdateDelay (ms) + */ + const queueCanvasDatasetMutation: QueueCanvasDatasetMutation = (patch, stateUpdateDelay = 0) => { + mutationsQueue.push({ + status: 'pending', + id: uuid(), + elementId: patch.elementId, + elementType: patch.elementType, + operationType: patch.operationType, + changes: patch.changes, + }); + + // Updating the mutations counter will re-render the component + if (stateUpdateDelay) { + setTimeout(() => { + setMutationsCounter(mutationsCounter + 1); + }, stateUpdateDelay); + } else { + setMutationsCounter(mutationsCounter + 1); + } + }; + + /** + * Debounces the database update invocation call. + * + * Helps avoid multiple DB updates and group them into one (by only considering the last one, which is the one that really matters). + * + * Due to debouncing, multiple database updates are avoided (when they happen in a close time-related burst), + * which is really important in a real-time context because it'll greatly reduce update events received by ALL subscribed clients. + * + * By eliminating burst updates and only considering the latest update, we greatly reduce the stream of updates sent to the DB. + * By ensuring those updates happen at most every 1 second (maxWait), which syncs the work being done locally with the remote, + * we ensure the work being done locally doesn't fall behind what's done on the remote. + * + * XXX Because we handle nodes/edges using different states, without debouncing then adding/removing a node would trigger 2 updates: + * - One for adding/removing the node + * - One for adding/removing the edge + * Thanks to debouncing, there is only one actual DB update. + */ + const debouncedUpdateUserCanvas = useDebouncedCallback( + (canvasRef: TypeOfRef | undefined, user: Partial, newCanvasDataset: CanvasDataset, previousCanvasDataset: CanvasDataset | undefined) => { + updateUserCanvas(canvasDocRef, user, canvasDataset, previousCanvasDataset); + }, + 100, // Wait 100ms for other changes to happen, if no change happen then invoke the update + { maxWait: 1000 }, // In any case, wait for no more than 1 second, at most + ); /** * When nodes or edges are modified, updates the persisted data in FaunaDB. * - * Persisted data are automatically loaded upon page refresh. + * Persisted data are automatically loaded when the stream is initialized. */ useEffect(() => { - // persistCanvasDatasetInLS(canvasDataset); - updateSharedCanvasDocument(canvasDataset); + // Only save changes once the stream has started, to avoid saving anything until the initial canvas dataset was initialized + if (isStreaming) { + debouncedUpdateUserCanvas(canvasDocRef, userSession, canvasDataset, previousCanvasDataset); + } }, [canvasDataset]); /** @@ -127,13 +219,15 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n * Ensures the "start" node and "end" node are always present. * * Will automatically create the start/end nodes, even when all the nodes have been deleted. + * Disabled until the stream has started to avoid creating the start node even before we got the initial canvas dataset from the stream. */ useEffect(() => { const existingStartNode: BaseNodeData | undefined = nodes?.find((node: BaseNodeData) => node?.data?.type === 'start'); const existingEndNode: BaseNodeData | undefined = nodes?.find((node: BaseNodeData) => node?.data?.type === 'end'); - if (!existingStartNode || !existingEndNode) { - console.info(`No "start" node found. Creating one automatically.`, nodes); + if ((!existingStartNode || !existingEndNode) && isStreaming) { + console.groupCollapsed('Creating default canvas dataset'); + console.info(`No "start" or "end" node found. Creating them automatically.`, nodes, edges, existingStartNode, existingEndNode); const startNode: BaseNodeData = createNodeFromDefaultProps(getDefaultNodePropsWithFallback('start')); const endNode: BaseNodeData = createNodeFromDefaultProps(getDefaultNodePropsWithFallback('end')); const newNodes = [ @@ -158,6 +252,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n clear(); setHasClearedUndoHistory(true); } + console.groupEnd(); } }, [nodes]); @@ -208,6 +303,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n return ( ); }; @@ -222,6 +318,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n return ( ); }; @@ -295,12 +392,14 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n style={{ position: 'absolute', top: 10, left: 20, zIndex: 999 }} >