From 396e7f1f5d4fd0f6ab7242a934637ab73a633dc2 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Tue, 9 Mar 2021 00:54:27 +0100 Subject: [PATCH 001/148] Use refreshInterval to automatically refresh the UI once the user has logged in --- src/components/hooks/useUser.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/hooks/useUser.ts b/src/components/hooks/useUser.ts index 5f33e31..d5fa2a0 100644 --- a/src/components/hooks/useUser.ts +++ b/src/components/hooks/useUser.ts @@ -16,7 +16,14 @@ const fetcher = (url: string) => export const useUser = (props: Props = {}) => { const { redirectTo, redirectIfFound } = props; - const { data, error } = useSWR('/api/user', fetcher); + const { data, error } = useSWR( + '/api/user', + fetcher, + { + // Automatically refresh the page once the user has logged in, to update the UI + refreshInterval: 1000, + }, + ); const isLoading = !error && !data; const user = data?.user; const hasUser = Boolean(user); From 82ce9d91c944eb011b571a14f7eeb9690e4d6f2f Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Tue, 9 Mar 2021 00:59:08 +0100 Subject: [PATCH 002/148] Improve logging --- src/components/AuthFormModal.tsx | 5 ++--- src/components/hooks/useUser.ts | 4 ---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/components/AuthFormModal.tsx b/src/components/AuthFormModal.tsx index 421105a..999cf17 100644 --- a/src/components/AuthFormModal.tsx +++ b/src/components/AuthFormModal.tsx @@ -35,7 +35,6 @@ const AuthFormModal = (props: Props) => { */ const onSubmit = async (event: MouseEvent): Promise => { event.preventDefault(); - console.log('email', email); try { localStorage?.setItem(LS_EMAIL_KEY, email); @@ -64,7 +63,7 @@ const AuthFormModal = (props: Props) => { console.error('An unexpected error happened occurred:', error); } } catch (e) { - console.log(e); + console.error(e); } }; @@ -72,7 +71,7 @@ const AuthFormModal = (props: Props) => { try { setEmail(localStorage?.getItem(LS_EMAIL_KEY) || ''); } catch (e) { - console.log(e); + console.error(e); } }, [mode]); diff --git a/src/components/hooks/useUser.ts b/src/components/hooks/useUser.ts index d5fa2a0..122cbdb 100644 --- a/src/components/hooks/useUser.ts +++ b/src/components/hooks/useUser.ts @@ -27,10 +27,6 @@ export const useUser = (props: Props = {}) => { const isLoading = !error && !data; const user = data?.user; const hasUser = Boolean(user); - console.log('user', user); - console.log('data', data); - console.log('error', error); - console.log('isLoading', isLoading); useEffect(() => { if (!redirectTo || isLoading) return; From f6a29d9bfaf69fa5b72fa2b24f6a03d670718c2a Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Tue, 9 Mar 2021 01:18:50 +0100 Subject: [PATCH 003/148] Figured it out --- src/components/AuthFormModal.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/AuthFormModal.tsx b/src/components/AuthFormModal.tsx index 999cf17..331c9be 100644 --- a/src/components/AuthFormModal.tsx +++ b/src/components/AuthFormModal.tsx @@ -11,6 +11,7 @@ import { useDisclosure, } from '@chakra-ui/react'; import { Magic } from 'magic-sdk'; +import Router from 'next/router'; import React, { useEffect, useState, @@ -45,6 +46,7 @@ const AuthFormModal = (props: Props) => { email: email, showUI: true, }); + console.info('User has logged in') const res = await fetch('/api/login', { method: 'POST', headers: { @@ -56,6 +58,7 @@ const AuthFormModal = (props: Props) => { if (res.status === 200) { onClose(); + Router.push('/'); // Forces a re-render } else { throw new Error(await res.text()); } From 258e390547525b908863f19a530c299eb20df40d Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 10:04:49 +0100 Subject: [PATCH 004/148] Refactor "magic" > magicAdmin to avoid confusion --- src/lib/magic.ts | 3 --- src/lib/magicAdmin.ts | 3 +++ src/pages/api/login.ts | 4 ++-- src/pages/api/logout.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) delete mode 100644 src/lib/magic.ts create mode 100644 src/lib/magicAdmin.ts diff --git a/src/lib/magic.ts b/src/lib/magic.ts deleted file mode 100644 index ba9c45d..0000000 --- a/src/lib/magic.ts +++ /dev/null @@ -1,3 +0,0 @@ -const { Magic } = require('@magic-sdk/admin'); - -export const magic = new Magic(process.env.MAGIC_SECRET_KEY); diff --git a/src/lib/magicAdmin.ts b/src/lib/magicAdmin.ts new file mode 100644 index 0000000..e911275 --- /dev/null +++ b/src/lib/magicAdmin.ts @@ -0,0 +1,3 @@ +const { Magic } = require('@magic-sdk/admin'); + +export const magicAdmin = new Magic(process.env.MAGIC_SECRET_KEY); diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts index 1062ab5..0af827d 100644 --- a/src/pages/api/login.ts +++ b/src/pages/api/login.ts @@ -3,7 +3,7 @@ import { NextApiResponse, } from 'next'; import { setLoginSession } from '../../lib/auth'; -import { magic } from '../../lib/magic'; +import { magicAdmin } from '../../lib/magicAdmin'; type EndpointRequest = NextApiRequest & { query: {}; @@ -12,7 +12,7 @@ type EndpointRequest = NextApiRequest & { export const login = async (req: EndpointRequest, res: NextApiResponse): Promise => { try { const didToken = req?.headers?.authorization?.substr(7); - const metadata = await magic?.users?.getMetadataByToken(didToken); + const metadata = await magicAdmin?.users?.getMetadataByToken(didToken); const session = { ...metadata }; await setLoginSession(res, session); diff --git a/src/pages/api/logout.ts b/src/pages/api/logout.ts index 69783d8..5865971 100644 --- a/src/pages/api/logout.ts +++ b/src/pages/api/logout.ts @@ -4,7 +4,7 @@ import { } from 'next'; import { getLoginSession } from '../../lib/auth'; import { removeTokenCookie } from '../../lib/auth-cookies'; -import { magic } from '../../lib/magic'; +import { magicAdmin } from '../../lib/magicAdmin'; import { UserSession } from '../../types/auth/UserSession'; type EndpointRequest = NextApiRequest & { @@ -16,7 +16,7 @@ export const logout = async (req: EndpointRequest, res: NextApiResponse): Promis const session: UserSession | undefined = await getLoginSession(req); if (session?.issuer) { - await magic.users.logoutByIssuer(session?.issuer as string); + await magicAdmin.users.logoutByIssuer(session?.issuer as string); removeTokenCookie(res); } } catch (error) { From cbdb97b457b42a5263bea37ae619af7673fd634d Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 10:18:08 +0100 Subject: [PATCH 005/148] Refactoring magicClient and make sure neither crash when called from an unexpected runtime --- src/components/AuthFormModal.tsx | 7 +++---- src/lib/magicAdmin.ts | 11 ++++++++++- src/lib/magicClient.ts | 12 ++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 src/lib/magicClient.ts diff --git a/src/components/AuthFormModal.tsx b/src/components/AuthFormModal.tsx index 331c9be..6a62b7d 100644 --- a/src/components/AuthFormModal.tsx +++ b/src/components/AuthFormModal.tsx @@ -16,6 +16,7 @@ import React, { useEffect, useState, } from 'react'; +import { magicClient } from '../lib/magicClient'; type Props = { mode: 'login' | 'create-account'; @@ -41,8 +42,7 @@ const AuthFormModal = (props: Props) => { localStorage?.setItem(LS_EMAIL_KEY, email); try { - const magic = new Magic(process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY as string); - const didToken = await magic.auth.loginWithMagicLink({ + const didToken = await magicClient.auth.loginWithMagicLink({ email: email, showUI: true, }); @@ -79,8 +79,7 @@ const AuthFormModal = (props: Props) => { }, [mode]); useEffect(() => { - const magic = new Magic(process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY as string); - magic.preload(); // See https://docs.magic.link/client-sdk/web/api-reference#preload + magicClient.preload(); // See https://docs.magic.link/client-sdk/web/api-reference#preload }, []); return ( diff --git a/src/lib/magicAdmin.ts b/src/lib/magicAdmin.ts index e911275..e7fbc7f 100644 --- a/src/lib/magicAdmin.ts +++ b/src/lib/magicAdmin.ts @@ -1,3 +1,12 @@ +import { isBrowser } from '@unly/utils'; const { Magic } = require('@magic-sdk/admin'); -export const magicAdmin = new Magic(process.env.MAGIC_SECRET_KEY); +/** + * Initialize a Magic Link admin instance (on the server). + * + * Acts as a singleton. + * Won't crash if called on the browser (universal). + * + * @see https://docs.magic.link/admin-sdk/node/get-started#installation + */ +export const magicAdmin = !isBrowser() ? new Magic(process.env.MAGIC_SECRET_KEY) : null; diff --git a/src/lib/magicClient.ts b/src/lib/magicClient.ts new file mode 100644 index 0000000..feed663 --- /dev/null +++ b/src/lib/magicClient.ts @@ -0,0 +1,12 @@ +const { Magic } = require('magic-sdk'); +import { isBrowser } from '@unly/utils'; + +/** + * Initialize a Magic Link client instance (on the browser). + * + * Acts as a singleton. + * Won't crash if called on the server (universal). + * + * @see https://docs.magic.link/client-sdk/web/get-started#create-an-sdk-instance + */ +export const magicClient = isBrowser() ? new Magic(process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY) : null; From 73428b52dda655bd971a6ad08839741265a5c369 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 10:58:00 +0100 Subject: [PATCH 006/148] Improve TS defs + add doc for auth + increase security by calling "validate" on the didToken --- src/lib/auth-cookies.ts | 41 +++++++++++++++++++++++++++++++++++------ src/lib/auth.ts | 22 ++++++++++++++++++---- src/pages/api/login.ts | 30 ++++++++++++++++++++++++++---- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/lib/auth-cookies.ts b/src/lib/auth-cookies.ts index 0736b99..76bb61b 100644 --- a/src/lib/auth-cookies.ts +++ b/src/lib/auth-cookies.ts @@ -7,12 +7,24 @@ import { NextApiResponse, } from 'next'; +type Cookies = { [key: string]: string } + const COOKIE_TOKEN_NAME = 'token'; export const MAX_AGE = 60 * 60 * 12; // 12 hours +/** + * Writes the authentication cookie token in the browser. + * + * The cookie is only readable by the server (httpOnly), not by the browser. + * + * @param res + * @param token + * + * @see https://owasp.org/www-community/HttpOnly + */ export function setTokenCookie(res: NextApiResponse, token: string) { - const cookie = serialize(COOKIE_TOKEN_NAME, token, { + const cookie: string = serialize(COOKIE_TOKEN_NAME, token, { maxAge: MAX_AGE, expires: new Date(Date.now() + MAX_AGE * 1000), httpOnly: true, @@ -24,8 +36,13 @@ export function setTokenCookie(res: NextApiResponse, token: string) { res.setHeader('Set-Cookie', cookie); } +/** + * Deletes the authentication cookie from the browser. + * + * @param res + */ export function removeTokenCookie(res: NextApiResponse) { - const cookie = serialize(COOKIE_TOKEN_NAME, '', { + const cookie: string = serialize(COOKIE_TOKEN_NAME, '', { maxAge: -1, path: '/', }); @@ -33,7 +50,14 @@ export function removeTokenCookie(res: NextApiResponse) { res.setHeader('Set-Cookie', cookie); } -export function parseCookies(req: NextApiRequest) { +/** + * Parse the cookies, if they've not been parsed already. + * + * Works for Next.js API routes and pages. (behavior is slightly different between both) + * + * @param req + */ +export function parseCookies(req: NextApiRequest): Cookies { // For API Routes we don't need to parse the cookies. if (req.cookies) return req.cookies; @@ -42,7 +66,12 @@ export function parseCookies(req: NextApiRequest) { return parse(cookie || ''); } -export function getTokenCookie(req: NextApiRequest) { - const cookies = parseCookies(req); - return cookies[COOKIE_TOKEN_NAME]; +/** + * Returns the authentication token from the cookie. + * + * @param req + */ +export function getTokenCookie(req: NextApiRequest): string | null { + const cookies: Cookies = parseCookies(req); + return cookies?.[COOKIE_TOKEN_NAME]; } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 0d4a182..42395c7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,5 @@ import Iron from '@hapi/iron'; +import { MagicUserMetadata } from '@magic-sdk/admin'; import { NextApiRequest, NextApiResponse, @@ -16,17 +17,30 @@ type EndpointRequest = NextApiRequest & { query: {}; }; -export const setLoginSession = async (res: NextApiResponse, session: UserSession): Promise => { +/** + * Writes a login cookie token to the browser. + * + * Uses user metadata (provided by Magic), and augments them (using custom login logic). + * + * @param res + * @param userMetadata + */ +export const setLoginSession = async (res: NextApiResponse, userMetadata: MagicUserMetadata): Promise => { const createdAt = Date.now(); + // Create a session object with a max age that we can validate later - const obj = { ...session, createdAt, maxAge: MAX_AGE }; - const token = await Iron.seal(obj, TOKEN_SECRET, Iron.defaults); + const userSession: UserSession = { + ...userMetadata, + createdAt, + maxAge: MAX_AGE, + }; + const token: string = await Iron.seal(userSession, TOKEN_SECRET, Iron.defaults); setTokenCookie(res, token); }; export const getLoginSession = async (req: EndpointRequest, res?: NextApiResponse): Promise => { - const token = getTokenCookie(req); + const token: string | null = getTokenCookie(req); if (!token) return; diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts index 0af827d..7a2a458 100644 --- a/src/pages/api/login.ts +++ b/src/pages/api/login.ts @@ -1,3 +1,4 @@ +import { MagicUserMetadata } from '@magic-sdk/admin'; import { NextApiRequest, NextApiResponse, @@ -9,16 +10,37 @@ type EndpointRequest = NextApiRequest & { query: {}; }; +/** + * Authenticates a user. + * + * Called when Magic Link returns a didToken after calling "loginWithMagicLink". + * Called from AuthFormModal. + * + * Parses the authorization Bearer token (didToken), fetches the user's metadata and then generates a cookie containing the authentication token. + * + * @param req + * @param res + * + * @see https://docs.magic.link/decentralized-id#what-is-a-did-token What is a DID token? + * @see https://docs.magic.link/admin-sdk/node/api-reference#parseauthorizationheader + * @see https://docs.magic.link/admin-sdk/php/laravel/api-reference#validate + */ export const login = async (req: EndpointRequest, res: NextApiResponse): Promise => { try { - const didToken = req?.headers?.authorization?.substr(7); - const metadata = await magicAdmin?.users?.getMetadataByToken(didToken); - const session = { ...metadata }; + const didToken = magicAdmin.utils.parseAuthorizationHeader(req?.headers?.authorization || ''); - await setLoginSession(res, session); + // XXX It is important to always validate the DID Token before using it. Will throw if invalid. + magicAdmin.token.validate(didToken); + + // The Magic API returns a few metadata, including the issuer and the user email + const userMetadata: MagicUserMetadata = await magicAdmin.users.getMetadataByToken(didToken); + + // Those metadata are then used to generate a login session (Magic metadata + custom login metadata) + await setLoginSession(res, userMetadata); res.status(200).send({ done: true }); } catch (error) { + console.error(error); res.status(error.status || 500).end(error.message); } }; From 31fbf9dc1dfc1fbf3a2b0946f0667818286f18b5 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 10:58:11 +0100 Subject: [PATCH 007/148] Add .env doc --- .env.local.example | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.env.local.example b/.env.local.example index 9340036..b1aa686 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,3 +1,16 @@ +# XXX Duplicate this file as ".env.local" and fill-in below environment variables. +# This file is used for local development only (localhost). +# You'll need to add those environment variables as "Vercel environment variables" manually, too. + +# Magic Link provides a "publishable key" which is used on the browser (and thus, public). +# Go to https://dashboard.magic.link/ > API Keys > Test "Publishable key" NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk_test_C156E7D8B3720D75 + +# 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= + +# Used by @hapi/iron, must be a string of 32 characters min. Can be any value. +# 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) TOKEN_SECRET= From a1ad950f234627815763c7479df4c650695cc8a7 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 11:01:59 +0100 Subject: [PATCH 008/148] Refactor libs (group them using folders) --- src/components/AuthFormModal.tsx | 2 +- src/components/editor/CanvasContainer.tsx | 2 +- src/lib/{ => auth}/auth-cookies.ts | 0 src/lib/{ => auth}/auth.ts | 2 +- src/lib/{ => auth}/magicAdmin.ts | 0 src/lib/{ => auth}/magicClient.ts | 0 src/lib/{ => faunadb}/faunadbClient.ts | 2 +- src/pages/api/login.ts | 4 ++-- src/pages/api/logout.ts | 6 +++--- src/pages/api/user.ts | 2 +- src/pages/index.tsx | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) rename src/lib/{ => auth}/auth-cookies.ts (100%) rename src/lib/{ => auth}/auth.ts (96%) rename src/lib/{ => auth}/magicAdmin.ts (100%) rename src/lib/{ => auth}/magicClient.ts (100%) rename src/lib/{ => faunadb}/faunadbClient.ts (98%) diff --git a/src/components/AuthFormModal.tsx b/src/components/AuthFormModal.tsx index 6a62b7d..9cb6bce 100644 --- a/src/components/AuthFormModal.tsx +++ b/src/components/AuthFormModal.tsx @@ -16,7 +16,7 @@ import React, { useEffect, useState, } from 'react'; -import { magicClient } from '../lib/magicClient'; +import { magicClient } from '../lib/auth/magicClient'; type Props = { mode: 'login' | 'create-account'; diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index 88a6645..961405a 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -16,7 +16,7 @@ import { useUndo, } from 'reaflow'; import { useRecoilState } from 'recoil'; -import { updateSharedCanvasDocument } from '../../lib/faunadbClient'; +import { updateSharedCanvasDocument } from '../../lib/faunadb/faunadbClient'; import settings from '../../settings'; import { blockPickerMenuSelector } from '../../states/blockPickerMenuState'; import { canvasDatasetSelector } from '../../states/canvasDatasetSelector'; diff --git a/src/lib/auth-cookies.ts b/src/lib/auth/auth-cookies.ts similarity index 100% rename from src/lib/auth-cookies.ts rename to src/lib/auth/auth-cookies.ts diff --git a/src/lib/auth.ts b/src/lib/auth/auth.ts similarity index 96% rename from src/lib/auth.ts rename to src/lib/auth/auth.ts index 42395c7..24ce573 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth/auth.ts @@ -4,7 +4,7 @@ import { NextApiRequest, NextApiResponse, } from 'next'; -import { UserSession } from '../types/auth/UserSession'; +import { UserSession } from '../../types/auth/UserSession'; import { getTokenCookie, MAX_AGE, diff --git a/src/lib/magicAdmin.ts b/src/lib/auth/magicAdmin.ts similarity index 100% rename from src/lib/magicAdmin.ts rename to src/lib/auth/magicAdmin.ts diff --git a/src/lib/magicClient.ts b/src/lib/auth/magicClient.ts similarity index 100% rename from src/lib/magicClient.ts rename to src/lib/auth/magicClient.ts diff --git a/src/lib/faunadbClient.ts b/src/lib/faunadb/faunadbClient.ts similarity index 98% rename from src/lib/faunadbClient.ts rename to src/lib/faunadb/faunadbClient.ts index 70516cf..458c230 100644 --- a/src/lib/faunadbClient.ts +++ b/src/lib/faunadb/faunadbClient.ts @@ -7,7 +7,7 @@ import faunadb, { import { Subscription } from 'faunadb/src/types/Stream'; import * as Fauna from 'faunadb/src/types/values'; import isEqual from 'lodash.isequal'; -import { CanvasDataset } from '../types/CanvasDataset'; +import { CanvasDataset } from '../../types/CanvasDataset'; const { Ref, Collection } = faunadb.query; const client = new faunadb.Client({ secret: 'fnAEDdp0CWACBZUTQvkktsqAQeW03uDhZYY0Ttlg' }); diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts index 7a2a458..b6fecf9 100644 --- a/src/pages/api/login.ts +++ b/src/pages/api/login.ts @@ -3,8 +3,8 @@ import { NextApiRequest, NextApiResponse, } from 'next'; -import { setLoginSession } from '../../lib/auth'; -import { magicAdmin } from '../../lib/magicAdmin'; +import { setLoginSession } from '../../lib/auth/auth'; +import { magicAdmin } from '../../lib/auth/magicAdmin'; type EndpointRequest = NextApiRequest & { query: {}; diff --git a/src/pages/api/logout.ts b/src/pages/api/logout.ts index 5865971..2d16b15 100644 --- a/src/pages/api/logout.ts +++ b/src/pages/api/logout.ts @@ -2,9 +2,9 @@ import { NextApiRequest, NextApiResponse, } from 'next'; -import { getLoginSession } from '../../lib/auth'; -import { removeTokenCookie } from '../../lib/auth-cookies'; -import { magicAdmin } from '../../lib/magicAdmin'; +import { getLoginSession } from '../../lib/auth/auth'; +import { removeTokenCookie } from '../../lib/auth/auth-cookies'; +import { magicAdmin } from '../../lib/auth/magicAdmin'; import { UserSession } from '../../types/auth/UserSession'; type EndpointRequest = NextApiRequest & { diff --git a/src/pages/api/user.ts b/src/pages/api/user.ts index 775e5ae..23ba818 100644 --- a/src/pages/api/user.ts +++ b/src/pages/api/user.ts @@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse, } from 'next'; -import { getLoginSession } from '../../lib/auth'; +import { getLoginSession } from '../../lib/auth/auth'; import { UserSession } from '../../types/auth/UserSession'; type EndpointRequest = NextApiRequest & { diff --git a/src/pages/index.tsx b/src/pages/index.tsx index e45c051..89f97a2 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -5,7 +5,7 @@ import EditorContainer from '../components/editor/EditorContainer'; import { useUser } from '../components/hooks/useUser'; import Layout from '../components/Layout'; import { setRecoilExternalState } from '../components/RecoilExternalStatePortal'; -import { startStreamingCanvasDataset } from '../lib/faunadbClient'; +import { startStreamingCanvasDataset } from '../lib/faunadb/faunadbClient'; import { canvasDatasetSelector } from '../states/canvasDatasetSelector'; import { CanvasDataset } from '../types/CanvasDataset'; From cf2d0bf4a4a4db66758a91fa3ef335b34d8fce34 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 11:15:23 +0100 Subject: [PATCH 009/148] Refactoring auth + more doc --- .../auth/{auth-cookies.ts => authCookies.ts} | 2 +- src/lib/auth/{auth.ts => userSession.ts} | 24 ++++++++++++++----- src/pages/api/login.ts | 6 ++--- src/pages/api/logout.ts | 6 ++--- src/pages/api/user.ts | 14 ++++++++--- 5 files changed, 36 insertions(+), 16 deletions(-) rename src/lib/auth/{auth-cookies.ts => authCookies.ts} (97%) rename src/lib/auth/{auth.ts => userSession.ts} (62%) diff --git a/src/lib/auth/auth-cookies.ts b/src/lib/auth/authCookies.ts similarity index 97% rename from src/lib/auth/auth-cookies.ts rename to src/lib/auth/authCookies.ts index 76bb61b..55b4c1d 100644 --- a/src/lib/auth/auth-cookies.ts +++ b/src/lib/auth/authCookies.ts @@ -67,7 +67,7 @@ export function parseCookies(req: NextApiRequest): Cookies { } /** - * Returns the authentication token from the cookie. + * Returns the user session token from the cookie. * * @param req */ diff --git a/src/lib/auth/auth.ts b/src/lib/auth/userSession.ts similarity index 62% rename from src/lib/auth/auth.ts rename to src/lib/auth/userSession.ts index 24ce573..a917d2f 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/userSession.ts @@ -9,14 +9,18 @@ import { getTokenCookie, MAX_AGE, setTokenCookie, -} from './auth-cookies'; - -const TOKEN_SECRET = process.env.TOKEN_SECRET as string; +} from './authCookies'; type EndpointRequest = NextApiRequest & { query: {}; }; +const TOKEN_SECRET = process.env.TOKEN_SECRET as string; + +if(!TOKEN_SECRET || TOKEN_SECRET?.length < 32){ + throw new Error(`You must define a "TOKEN_SECRET" environment variable of at least 32 characters in order to use authentication. Found "${TOKEN_SECRET}".`); +} + /** * Writes a login cookie token to the browser. * @@ -25,7 +29,7 @@ type EndpointRequest = NextApiRequest & { * @param res * @param userMetadata */ -export const setLoginSession = async (res: NextApiResponse, userMetadata: MagicUserMetadata): Promise => { +export const setUserSession = async (res: NextApiResponse, userMetadata: MagicUserMetadata): Promise => { const createdAt = Date.now(); // Create a session object with a max age that we can validate later @@ -39,12 +43,20 @@ export const setLoginSession = async (res: NextApiResponse, userMetadata: MagicU setTokenCookie(res, token); }; -export const getLoginSession = async (req: EndpointRequest, res?: NextApiResponse): Promise => { +/** + * Returns the user session. + * + * Resolves the user session by unsealing the token contained in the cookie. + * + * @param req + * @param res + */ +export const getUserSession = async (req: EndpointRequest, res?: NextApiResponse): Promise => { const token: string | null = getTokenCookie(req); if (!token) return; - const session = await Iron.unseal(token, TOKEN_SECRET, Iron.defaults); + const session: UserSession = await Iron.unseal(token, TOKEN_SECRET, Iron.defaults); const expiresAt = session.createdAt + session.maxAge * 1000; // Validate the expiration date of the session diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts index b6fecf9..ff9cfc7 100644 --- a/src/pages/api/login.ts +++ b/src/pages/api/login.ts @@ -3,7 +3,7 @@ import { NextApiRequest, NextApiResponse, } from 'next'; -import { setLoginSession } from '../../lib/auth/auth'; +import { setUserSession } from '../../lib/auth/userSession'; import { magicAdmin } from '../../lib/auth/magicAdmin'; type EndpointRequest = NextApiRequest & { @@ -16,7 +16,7 @@ type EndpointRequest = NextApiRequest & { * Called when Magic Link returns a didToken after calling "loginWithMagicLink". * Called from AuthFormModal. * - * Parses the authorization Bearer token (didToken), fetches the user's metadata and then generates a cookie containing the authentication token. + * Parses the authorization Bearer token (didToken), fetches the user's metadata and then generates a cookie containing the user session token. * * @param req * @param res @@ -36,7 +36,7 @@ export const login = async (req: EndpointRequest, res: NextApiResponse): Promise const userMetadata: MagicUserMetadata = await magicAdmin.users.getMetadataByToken(didToken); // Those metadata are then used to generate a login session (Magic metadata + custom login metadata) - await setLoginSession(res, userMetadata); + await setUserSession(res, userMetadata); res.status(200).send({ done: true }); } catch (error) { diff --git a/src/pages/api/logout.ts b/src/pages/api/logout.ts index 2d16b15..0e64dbf 100644 --- a/src/pages/api/logout.ts +++ b/src/pages/api/logout.ts @@ -2,8 +2,8 @@ import { NextApiRequest, NextApiResponse, } from 'next'; -import { getLoginSession } from '../../lib/auth/auth'; -import { removeTokenCookie } from '../../lib/auth/auth-cookies'; +import { getUserSession } from '../../lib/auth/userSession'; +import { removeTokenCookie } from '../../lib/auth/authCookies'; import { magicAdmin } from '../../lib/auth/magicAdmin'; import { UserSession } from '../../types/auth/UserSession'; @@ -13,7 +13,7 @@ type EndpointRequest = NextApiRequest & { export const logout = async (req: EndpointRequest, res: NextApiResponse): Promise => { try { - const session: UserSession | undefined = await getLoginSession(req); + const session: UserSession | undefined = await getUserSession(req); if (session?.issuer) { await magicAdmin.users.logoutByIssuer(session?.issuer as string); diff --git a/src/pages/api/user.ts b/src/pages/api/user.ts index 23ba818..76c1e70 100644 --- a/src/pages/api/user.ts +++ b/src/pages/api/user.ts @@ -2,20 +2,28 @@ import { NextApiRequest, NextApiResponse, } from 'next'; -import { getLoginSession } from '../../lib/auth/auth'; +import { getUserSession } from '../../lib/auth/userSession'; import { UserSession } from '../../types/auth/UserSession'; type EndpointRequest = NextApiRequest & { query: {}; }; +/** + * Returns the user session from the server-only cookie. + * + * Because the cookie containing the user session token can only be read by the server, we must use an API endpoint to retrieve it. + * + * @param req + * @param res + */ export const user = async (req: EndpointRequest, res: NextApiResponse): Promise => { - const session: UserSession | undefined = await getLoginSession(req); + const userSession: UserSession | undefined = await getUserSession(req); // After getting the session you may want to fetch for the user instead // of sending the session's payload directly, this example doesn't have a DB // so it won't matter in this case - res.status(200).json({ user: session || null }); + res.status(200).json({ user: userSession || null }); } export default user; From 5b2a6e8a148653bb4f2c7c5792ac7f4b7a4472ff Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 11:46:29 +0100 Subject: [PATCH 010/148] Refactoring crypto --- src/lib/auth/crypto.ts | 21 +++++++++++++++++++++ src/lib/auth/userSession.ts | 15 +++++---------- 2 files changed, 26 insertions(+), 10 deletions(-) create mode 100644 src/lib/auth/crypto.ts diff --git a/src/lib/auth/crypto.ts b/src/lib/auth/crypto.ts new file mode 100644 index 0000000..bfd9477 --- /dev/null +++ b/src/lib/auth/crypto.ts @@ -0,0 +1,21 @@ +import Iron from '@hapi/iron' + +const TOKEN_SECRET = process.env.TOKEN_SECRET as string; + +if (!TOKEN_SECRET || TOKEN_SECRET?.length < 32) { + throw new Error(`You must define a "TOKEN_SECRET" environment variable of at least 32 characters in order to use authentication. Found "${TOKEN_SECRET}".`); +} + +/** + * Encrypts the data into a token (as string). + * + * @param data + */ +export const encryptData = async (data: Data): Promise => Iron.seal(data, TOKEN_SECRET, Iron.defaults); + +/** + * Decrypts a token and returns the data contains within. + * + * @param token + */ +export const decryptToken = async (token: string): Promise => Iron.unseal(token, TOKEN_SECRET, Iron.defaults); diff --git a/src/lib/auth/userSession.ts b/src/lib/auth/userSession.ts index a917d2f..60b30bd 100644 --- a/src/lib/auth/userSession.ts +++ b/src/lib/auth/userSession.ts @@ -10,19 +10,14 @@ import { MAX_AGE, setTokenCookie, } from './authCookies'; +import { decryptToken, encryptData } from './crypto'; type EndpointRequest = NextApiRequest & { query: {}; }; -const TOKEN_SECRET = process.env.TOKEN_SECRET as string; - -if(!TOKEN_SECRET || TOKEN_SECRET?.length < 32){ - throw new Error(`You must define a "TOKEN_SECRET" environment variable of at least 32 characters in order to use authentication. Found "${TOKEN_SECRET}".`); -} - /** - * Writes a login cookie token to the browser. + * Writes the user session cookie token to the browser. * * Uses user metadata (provided by Magic), and augments them (using custom login logic). * @@ -38,7 +33,7 @@ export const setUserSession = async (res: NextApiResponse, userMetadata: MagicUs createdAt, maxAge: MAX_AGE, }; - const token: string = await Iron.seal(userSession, TOKEN_SECRET, Iron.defaults); + const token: string = await encryptData(userSession); setTokenCookie(res, token); }; @@ -46,7 +41,7 @@ export const setUserSession = async (res: NextApiResponse, userMetadata: MagicUs /** * Returns the user session. * - * Resolves the user session by unsealing the token contained in the cookie. + * Resolves the user session by decrypting the token contained in the cookie. * * @param req * @param res @@ -56,7 +51,7 @@ export const getUserSession = async (req: EndpointRequest, res?: NextApiResponse if (!token) return; - const session: UserSession = await Iron.unseal(token, TOKEN_SECRET, Iron.defaults); + const session: UserSession = await decryptToken(token); const expiresAt = session.createdAt + session.maxAge * 1000; // Validate the expiration date of the session From 5cfd2356df28b9d725170ea04fa4718ab55c4d4b Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 11:53:48 +0100 Subject: [PATCH 011/148] Rename TOKEN_SECRET > CRYPTO_TOKEN_SECRET --- .env.local.example | 2 +- src/lib/auth/crypto.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.env.local.example b/.env.local.example index b1aa686..fef7abd 100644 --- a/.env.local.example +++ b/.env.local.example @@ -13,4 +13,4 @@ MAGIC_SECRET_KEY= # Used by @hapi/iron, must be a string of 32 characters min. Can be any value. # 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) -TOKEN_SECRET= +CRYPTO_TOKEN_SECRET= diff --git a/src/lib/auth/crypto.ts b/src/lib/auth/crypto.ts index bfd9477..1ac93cb 100644 --- a/src/lib/auth/crypto.ts +++ b/src/lib/auth/crypto.ts @@ -1,9 +1,9 @@ import Iron from '@hapi/iron' -const TOKEN_SECRET = process.env.TOKEN_SECRET as string; +const CRYPTO_TOKEN_SECRET = process.env.CRYPTO_TOKEN_SECRET as string; -if (!TOKEN_SECRET || TOKEN_SECRET?.length < 32) { - throw new Error(`You must define a "TOKEN_SECRET" environment variable of at least 32 characters in order to use authentication. Found "${TOKEN_SECRET}".`); +if (!CRYPTO_TOKEN_SECRET || CRYPTO_TOKEN_SECRET?.length < 32) { + throw new Error(`You must define a "CRYPTO_TOKEN_SECRET" environment variable of at least 32 characters in order to use authentication. Found "${CRYPTO_TOKEN_SECRET}".`); } /** @@ -11,11 +11,11 @@ if (!TOKEN_SECRET || TOKEN_SECRET?.length < 32) { * * @param data */ -export const encryptData = async (data: Data): Promise => Iron.seal(data, TOKEN_SECRET, Iron.defaults); +export const encryptData = async (data: Data): Promise => Iron.seal(data, CRYPTO_TOKEN_SECRET, Iron.defaults); /** * Decrypts a token and returns the data contains within. * * @param token */ -export const decryptToken = async (token: string): Promise => Iron.unseal(token, TOKEN_SECRET, Iron.defaults); +export const decryptToken = async (token: string): Promise => Iron.unseal(token, CRYPTO_TOKEN_SECRET, Iron.defaults); From cd728a15640ff94b6c88fedfe3db962ca8f41dd9 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 12:56:06 +0100 Subject: [PATCH 012/148] Upon login, create new User on faunadb if it doesn't exist or get user if it exist + generate a personal token for the user and store it in the user session (cookie) --- .env.local.example | 5 +++ fql/setup.js | 10 ++++++ src/lib/auth/userSession.ts | 3 +- src/lib/faunadb/faunadb.ts | 21 ++++++++++++ src/lib/faunadb/models/userModel.ts | 34 ++++++++++++++++++++ src/pages/api/login.ts | 34 ++++++++++++++++++-- src/types/UserMetadataWithAuth.ts | 5 +++ src/types/faunadb/FaunadbBaseFields.ts | 4 +++ src/types/faunadb/FaunadbRecordBaseFields.ts | 6 ++++ src/types/faunadb/FaunadbToken.ts | 8 +++++ src/types/faunadb/User.ts | 5 +++ 11 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 fql/setup.js create mode 100644 src/lib/faunadb/faunadb.ts create mode 100644 src/lib/faunadb/models/userModel.ts create mode 100644 src/types/UserMetadataWithAuth.ts create mode 100644 src/types/faunadb/FaunadbBaseFields.ts create mode 100644 src/types/faunadb/FaunadbRecordBaseFields.ts create mode 100644 src/types/faunadb/FaunadbToken.ts create mode 100644 src/types/faunadb/User.ts diff --git a/.env.local.example b/.env.local.example index fef7abd..dd56698 100644 --- a/.env.local.example +++ b/.env.local.example @@ -14,3 +14,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/fql/setup.js b/fql/setup.js new file mode 100644 index 0000000..003f29f --- /dev/null +++ b/fql/setup.js @@ -0,0 +1,10 @@ +// Step 1: Create a "users" collection +CreateCollection({ name: "users" }); + +// Step 3: Create all relevant Indexes +CreateIndex({ + name: "users_by_email", + source: Collection("users"), + terms: [{ field: ["data", "email"] }], + unique: true +}); diff --git a/src/lib/auth/userSession.ts b/src/lib/auth/userSession.ts index 60b30bd..b044917 100644 --- a/src/lib/auth/userSession.ts +++ b/src/lib/auth/userSession.ts @@ -5,6 +5,7 @@ import { NextApiResponse, } from 'next'; import { UserSession } from '../../types/auth/UserSession'; +import { UserMetadataWithAuth } from '../../types/UserMetadataWithAuth'; import { getTokenCookie, MAX_AGE, @@ -24,7 +25,7 @@ type EndpointRequest = NextApiRequest & { * @param res * @param userMetadata */ -export const setUserSession = async (res: NextApiResponse, userMetadata: MagicUserMetadata): Promise => { +export const setUserSession = async (res: NextApiResponse, userMetadata: UserMetadataWithAuth): Promise => { const createdAt = Date.now(); // Create a session object with a max age that we can validate later diff --git a/src/lib/faunadb/faunadb.ts b/src/lib/faunadb/faunadb.ts new file mode 100644 index 0000000..aa36afc --- /dev/null +++ b/src/lib/faunadb/faunadb.ts @@ -0,0 +1,21 @@ +import faunadb from 'faunadb' + +const FAUNADB_SERVER_SECRET_KEY = process.env.FAUNADB_SERVER_SECRET_KEY as string; + +if (!FAUNADB_SERVER_SECRET_KEY || FAUNADB_SERVER_SECRET_KEY?.length < 32) { + throw new Error(`You must define a "FAUNADB_SERVER_SECRET_KEY" environment variable in order to use authentication. Found "${FAUNADB_SERVER_SECRET_KEY}".`); +} + +/** Alias to `faunadb.query` */ +export const q = faunadb.query + +/** + * Creates an authenticated FaunaDB client + * configured with the given `secret`. + */ +export function getClient(secret: string) { + return new faunadb.Client({ secret }) +} + +/** FaunaDB Client configured with our server secret. */ +export const adminClient = getClient(FAUNADB_SERVER_SECRET_KEY) diff --git a/src/lib/faunadb/models/userModel.ts b/src/lib/faunadb/models/userModel.ts new file mode 100644 index 0000000..b61ca55 --- /dev/null +++ b/src/lib/faunadb/models/userModel.ts @@ -0,0 +1,34 @@ +import Expr from 'faunadb/src/types/Expr'; +import { FaunadbToken } from '../../../types/faunadb/FaunadbToken'; +import { User } from '../../../types/faunadb/User'; +import { + adminClient, + getClient, + q, +} from '../faunadb'; + +export class UserModel { + async createUser(email: string): Promise { + return adminClient.query(q.Create(q.Collection('users'), { + data: { email }, + })); + } + + async getUserByEmail(email: string): Promise { + return adminClient.query( + q.Get(q.Match(q.Index('users_by_email'), email)), + ).catch(() => undefined); + } + + async obtainFaunaDBToken(user: User): Promise { + return adminClient.query( + q.Create(q.Tokens(), { instance: q.Select('ref', user) }), + ) + .then((res: FaunadbToken): string | undefined => res?.secret) + .catch(() => undefined); + } + + async invalidateFaunaDBToken(token: string) { + await getClient(token).query(q.Logout(true)); + } +} diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts index ff9cfc7..028fe7c 100644 --- a/src/pages/api/login.ts +++ b/src/pages/api/login.ts @@ -3,8 +3,11 @@ import { NextApiRequest, NextApiResponse, } from 'next'; -import { setUserSession } from '../../lib/auth/userSession'; import { magicAdmin } from '../../lib/auth/magicAdmin'; +import { setUserSession } from '../../lib/auth/userSession'; +import { UserModel } from '../../lib/faunadb/models/userModel'; +import { User } from '../../types/faunadb/User'; +import { UserMetadataWithAuth } from '../../types/UserMetadataWithAuth'; type EndpointRequest = NextApiRequest & { query: {}; @@ -34,9 +37,36 @@ export const login = async (req: EndpointRequest, res: NextApiResponse): Promise // The Magic API returns a few metadata, including the issuer and the user email const userMetadata: MagicUserMetadata = await magicAdmin.users.getMetadataByToken(didToken); + const userModel = new UserModel(); + + if (!userMetadata?.email) { + // This isn't supposed to happen, because Magic would have thrown an error before. + // But it might happen if there is a bug in Magic itself. + // In such case, there is nothing we can do and we should crash early. + throw new Error(`User doesn't have an email. Value: "${userMetadata?.email}"`); + } + + // Auto-detects new user sign-up when `getUserByEmail` resolves to `undefined` + const user: User = (await userModel.getUserByEmail(userMetadata?.email) ?? await userModel.createUser(userMetadata?.email)) as User; + + // Generates a FaunaDB token specific associated to this user + const faunaDBToken: string | undefined = await userModel.obtainFaunaDBToken(user); + + if(!faunaDBToken){ + // This isn't supposed to happen, because the user cannot not exist. + // But it might happen if our "FAUNADB_SERVER_SECRET_KEY" doesn't have the required permission to create a token. + // In such case, there is nothing we can do and we should crash early. + throw new Error(`Couldn't obtain a FaunaDB token for ${userMetadata?.email}.`); + } + + // Add the user token into the user metadata, so we can use it later to authenticate user actions using its own personal token + const userMetadataWithAuth: UserMetadataWithAuth = { + ...userMetadata, + faunaDBToken, + }; // Those metadata are then used to generate a login session (Magic metadata + custom login metadata) - await setUserSession(res, userMetadata); + await setUserSession(res, userMetadataWithAuth); res.status(200).send({ done: true }); } catch (error) { diff --git a/src/types/UserMetadataWithAuth.ts b/src/types/UserMetadataWithAuth.ts new file mode 100644 index 0000000..58c241e --- /dev/null +++ b/src/types/UserMetadataWithAuth.ts @@ -0,0 +1,5 @@ +import { MagicUserMetadata } from '@magic-sdk/admin'; + +export type UserMetadataWithAuth = MagicUserMetadata & { + faunaDBToken: string; +} diff --git a/src/types/faunadb/FaunadbBaseFields.ts b/src/types/faunadb/FaunadbBaseFields.ts new file mode 100644 index 0000000..637708a --- /dev/null +++ b/src/types/faunadb/FaunadbBaseFields.ts @@ -0,0 +1,4 @@ +export type FaunadbBaseFields = { + ref: any; + ts: number; +} diff --git a/src/types/faunadb/FaunadbRecordBaseFields.ts b/src/types/faunadb/FaunadbRecordBaseFields.ts new file mode 100644 index 0000000..523c5c0 --- /dev/null +++ b/src/types/faunadb/FaunadbRecordBaseFields.ts @@ -0,0 +1,6 @@ +import { GenericObject } from '../GenericObject'; +import { FaunadbBaseFields } from './FaunadbBaseFields'; + +export type FaunadbRecordBaseFields = FaunadbBaseFields & { + data: Data; +} diff --git a/src/types/faunadb/FaunadbToken.ts b/src/types/faunadb/FaunadbToken.ts new file mode 100644 index 0000000..ef5c136 --- /dev/null +++ b/src/types/faunadb/FaunadbToken.ts @@ -0,0 +1,8 @@ +import { values } from 'faunadb'; +import { FaunadbBaseFields } from './FaunadbBaseFields'; +import Ref = values.Ref; + +export type FaunadbToken = FaunadbBaseFields & { + instance: Ref; + secret: string; +} diff --git a/src/types/faunadb/User.ts b/src/types/faunadb/User.ts new file mode 100644 index 0000000..fd7bc85 --- /dev/null +++ b/src/types/faunadb/User.ts @@ -0,0 +1,5 @@ +import { FaunadbRecordBaseFields } from './FaunadbRecordBaseFields'; + +export type User = FaunadbRecordBaseFields<{ + email: string; +}> From c1a196aaf08489c05602f2077e094642004248fd Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 13:09:14 +0100 Subject: [PATCH 013/148] Doc --- src/lib/faunadb/models/userModel.ts | 19 +++++++++++++++++++ src/types/faunadb/FaunadbToken.ts | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/src/lib/faunadb/models/userModel.ts b/src/lib/faunadb/models/userModel.ts index b61ca55..5a28621 100644 --- a/src/lib/faunadb/models/userModel.ts +++ b/src/lib/faunadb/models/userModel.ts @@ -8,18 +8,37 @@ import { } from '../faunadb'; export class UserModel { + /** + * Creates a new user in the "users" collection. + * + * @param email + */ async createUser(email: string): Promise { return adminClient.query(q.Create(q.Collection('users'), { data: { email }, })); } + /** + * Find a user using the "users_by_email" index. + * + * @param email + */ async getUserByEmail(email: string): Promise { return adminClient.query( q.Get(q.Match(q.Index('users_by_email'), email)), ).catch(() => undefined); } + /** + * Generates a FaunaDB Token for the user. + * + * The token is associated to the user instance. + * + * @param user + * + * @see https://docs.fauna.com/fauna/current/api/fql/functions/tokens?lang=javascript + */ async obtainFaunaDBToken(user: User): Promise { return adminClient.query( q.Create(q.Tokens(), { instance: q.Select('ref', user) }), diff --git a/src/types/faunadb/FaunadbToken.ts b/src/types/faunadb/FaunadbToken.ts index ef5c136..c84df1d 100644 --- a/src/types/faunadb/FaunadbToken.ts +++ b/src/types/faunadb/FaunadbToken.ts @@ -2,7 +2,13 @@ import { values } from 'faunadb'; import { FaunadbBaseFields } from './FaunadbBaseFields'; import Ref = values.Ref; +/** + * A Token created by FaunaDB. + * + * @see https://docs.fauna.com/fauna/current/api/fql/functions/tokens?lang=javascript + */ export type FaunadbToken = FaunadbBaseFields & { instance: Ref; secret: string; + data?: any; // Optional, consider those as token metadata (not user's metadata) } From 4f741518c78d16ebac59032f0e270e793c945418 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 13:24:39 +0100 Subject: [PATCH 014/148] More doc --- src/lib/faunadb/faunadb.ts | 34 +++++++++++++++------------ src/lib/faunadb/faunadbAdminClient.ts | 18 ++++++++++++++ src/lib/faunadb/models/userModel.ts | 29 +++++++++++++++++------ 3 files changed, 59 insertions(+), 22 deletions(-) create mode 100644 src/lib/faunadb/faunadbAdminClient.ts diff --git a/src/lib/faunadb/faunadb.ts b/src/lib/faunadb/faunadb.ts index aa36afc..219d1c0 100644 --- a/src/lib/faunadb/faunadb.ts +++ b/src/lib/faunadb/faunadb.ts @@ -1,21 +1,25 @@ -import faunadb from 'faunadb' +import faunadb from 'faunadb'; +import { ClientConfig } from 'faunadb/src/types/Client'; -const FAUNADB_SERVER_SECRET_KEY = process.env.FAUNADB_SERVER_SECRET_KEY as string; - -if (!FAUNADB_SERVER_SECRET_KEY || FAUNADB_SERVER_SECRET_KEY?.length < 32) { - throw new Error(`You must define a "FAUNADB_SERVER_SECRET_KEY" environment variable in order to use authentication. Found "${FAUNADB_SERVER_SECRET_KEY}".`); -} - -/** Alias to `faunadb.query` */ -export const q = faunadb.query +/** + * Alias to `faunadb.query`. + * + * It is recommended to use destructuration to make JS-FQL queries identical to native FQL queries. + * This way, you can simply copy/past queries written in JS to the FQL Shell. + * + * @example `const { Get, Select } = q;` + */ +export const q = faunadb.query; /** - * Creates an authenticated FaunaDB client - * configured with the given `secret`. + * Creates an authenticated FaunaDB client configured with the given `secret`. + * + * @see https://docs.fauna.com/fauna/current/drivers/javascript.html#instantiating-a-client-and-issuing-queries */ export function getClient(secret: string) { - return new faunadb.Client({ secret }) -} + const options: ClientConfig = { + secret, + }; -/** FaunaDB Client configured with our server secret. */ -export const adminClient = getClient(FAUNADB_SERVER_SECRET_KEY) + return new faunadb.Client(options); +} diff --git a/src/lib/faunadb/faunadbAdminClient.ts b/src/lib/faunadb/faunadbAdminClient.ts new file mode 100644 index 0000000..afcd6f2 --- /dev/null +++ b/src/lib/faunadb/faunadbAdminClient.ts @@ -0,0 +1,18 @@ +import { isBrowser } from '@unly/utils'; +import { getClient } from './faunadb'; + +const FAUNADB_SERVER_SECRET_KEY = process.env.FAUNADB_SERVER_SECRET_KEY as string; + +if (!FAUNADB_SERVER_SECRET_KEY || FAUNADB_SERVER_SECRET_KEY?.length < 32) { + throw new Error(`You must define a "FAUNADB_SERVER_SECRET_KEY" environment variable in order to use authentication. Found "${FAUNADB_SERVER_SECRET_KEY}".`); +} + +/** + * Initialize a FaunaDB admin instance (on the server). + * + * Acts as a singleton. + * Won't crash if called on the browser (universal). + * + * @see https://docs.fauna.com/fauna/current/tutorials/crud.html?lang=javascript#obtain-an-admin-key + */ +export const faunadbAdminClient = !isBrowser() ? getClient(FAUNADB_SERVER_SECRET_KEY) : null; diff --git a/src/lib/faunadb/models/userModel.ts b/src/lib/faunadb/models/userModel.ts index 5a28621..ff095a2 100644 --- a/src/lib/faunadb/models/userModel.ts +++ b/src/lib/faunadb/models/userModel.ts @@ -2,10 +2,21 @@ import Expr from 'faunadb/src/types/Expr'; import { FaunadbToken } from '../../../types/faunadb/FaunadbToken'; import { User } from '../../../types/faunadb/User'; import { - adminClient, getClient, q, } from '../faunadb'; +import { faunadbAdminClient } from '../faunadbAdminClient'; + +const { + Create, + Collection, + Get, + Index, + Tokens, + Match, + Select, + Logout, +} = q; export class UserModel { /** @@ -14,7 +25,7 @@ export class UserModel { * @param email */ async createUser(email: string): Promise { - return adminClient.query(q.Create(q.Collection('users'), { + return faunadbAdminClient?.query(Create(Collection('users'), { data: { email }, })); } @@ -25,8 +36,8 @@ export class UserModel { * @param email */ async getUserByEmail(email: string): Promise { - return adminClient.query( - q.Get(q.Match(q.Index('users_by_email'), email)), + return faunadbAdminClient?.query( + Get(Match(Index('users_by_email'), email)), ).catch(() => undefined); } @@ -40,14 +51,18 @@ export class UserModel { * @see https://docs.fauna.com/fauna/current/api/fql/functions/tokens?lang=javascript */ async obtainFaunaDBToken(user: User): Promise { - return adminClient.query( - q.Create(q.Tokens(), { instance: q.Select('ref', user) }), + return faunadbAdminClient?.query( + Create(Tokens(), { instance: Select('ref', user) }), ) .then((res: FaunadbToken): string | undefined => res?.secret) .catch(() => undefined); } + /** + * + * @param token + */ async invalidateFaunaDBToken(token: string) { - await getClient(token).query(q.Logout(true)); + await getClient(token)?.query(Logout(true)); } } From dc51fa3523a530bddb1b12150d38ab174513f38f Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 13:28:24 +0100 Subject: [PATCH 015/148] Update UserSession --- src/pages/index.tsx | 1 - src/types/auth/UserSession.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 89f97a2..9570658 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -2,7 +2,6 @@ import { isBrowser } from '@unly/utils'; import { useState } from 'react'; import DisplayOnBrowserMount from '../components/DisplayOnBrowserMount'; import EditorContainer from '../components/editor/EditorContainer'; -import { useUser } from '../components/hooks/useUser'; import Layout from '../components/Layout'; import { setRecoilExternalState } from '../components/RecoilExternalStatePortal'; import { startStreamingCanvasDataset } from '../lib/faunadb/faunadbClient'; diff --git a/src/types/auth/UserSession.ts b/src/types/auth/UserSession.ts index 3c8e0c0..da1a9c6 100644 --- a/src/types/auth/UserSession.ts +++ b/src/types/auth/UserSession.ts @@ -1,6 +1,6 @@ -import { MagicUserMetadata } from '@magic-sdk/admin'; +import { UserMetadataWithAuth } from '../UserMetadataWithAuth'; -export type UserSession = MagicUserMetadata & { +export type UserSession = UserMetadataWithAuth & { createdAt: number; maxAge: number; }; From 7f9b738e5a505def600024099269a3b0dbfd5595 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 18:54:45 +0100 Subject: [PATCH 016/148] Refactor streaming (WIP) --- src/components/FaunaDBCanvasStream.tsx | 49 ++++++ src/components/editor/CanvasContainer.tsx | 14 +- src/components/hooks/useUser.ts | 3 +- src/pages/_app.tsx | 2 - src/pages/index.tsx | 38 ++--- src/types/faunadb/CanvasResult.ts | 4 + src/types/faunadb/CanvasStream.ts | 4 + .../faunadb/FaunadbStreamVersionEvent.ts | 8 + src/utils/canvasStream.ts | 149 ++++++++++++++++++ 9 files changed, 240 insertions(+), 31 deletions(-) create mode 100644 src/components/FaunaDBCanvasStream.tsx create mode 100644 src/types/faunadb/CanvasResult.ts create mode 100644 src/types/faunadb/CanvasStream.ts create mode 100644 src/types/faunadb/FaunadbStreamVersionEvent.ts create mode 100644 src/utils/canvasStream.ts diff --git a/src/components/FaunaDBCanvasStream.tsx b/src/components/FaunaDBCanvasStream.tsx new file mode 100644 index 0000000..1e8b864 --- /dev/null +++ b/src/components/FaunaDBCanvasStream.tsx @@ -0,0 +1,49 @@ +import { isBrowser } from '@unly/utils'; +import React, { + useEffect, + useState, +} from 'react'; +import { UserSession } from '../types/auth/UserSession'; +import { + OnInit, + OnUpdate, +} from '../types/faunadb/CanvasStream'; +import { initStream } from '../utils/canvasStream'; +import { useUser } from './hooks/useUser'; + +type Props = { + onInit: OnInit; + onUpdate: OnUpdate; +} + +/** + * TODO + */ +const FaunaDBCanvasStream: React.FunctionComponent = (props) => { + const { + onInit, + onUpdate, + } = props; + + // Used to avoid starting several streams from the same browser + const [hasStreamStarted, setHasStreamStarted] = useState(false); + + const user: UserSession | null = useUser(); + console.log('FaunaDBCanvasStream user', user); + + if (!isBrowser()) { + return null; + } + + useEffect(() => { + if (!hasStreamStarted) { + setHasStreamStarted(true); + + initStream(user, onInit, onUpdate); + } + }, []); + + return null; +}; + +export default FaunaDBCanvasStream; diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index 961405a..18cc8b8 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -16,7 +16,6 @@ import { useUndo, } from 'reaflow'; import { useRecoilState } from 'recoil'; -import { updateSharedCanvasDocument } from '../../lib/faunadb/faunadbClient'; import settings from '../../settings'; import { blockPickerMenuSelector } from '../../states/blockPickerMenuState'; import { canvasDatasetSelector } from '../../states/canvasDatasetSelector'; @@ -25,6 +24,10 @@ import { nodesSelector } from '../../states/nodesState'; import { selectedEdgesSelector } from '../../states/selectedEdgesState'; import { selectedNodesSelector } from '../../states/selectedNodesState'; import BaseNodeData from '../../types/BaseNodeData'; +import { + onInit, + onUpdate, +} from '../../utils/canvasStream'; import { isOlderThan } from '../../utils/date'; import { createNodeFromDefaultProps, @@ -32,6 +35,7 @@ import { } from '../../utils/nodes'; import canvasUtilsContext from '../context/canvasUtilsContext'; import BaseEdge from '../edges/BaseEdge'; +import FaunaDBCanvasStream from '../FaunaDBCanvasStream'; import NodeRouter from '../nodes/NodeRouter'; type Props = { @@ -86,7 +90,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n */ useEffect(() => { // persistCanvasDatasetInLS(canvasDataset); - updateSharedCanvasDocument(canvasDataset); + // updateSharedCanvasDocument(canvasDataset); }, [canvasDataset]); /** @@ -374,6 +378,12 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n onLayoutChange={layout => console.log('Layout', layout)} layoutOptions={elkLayoutOptions} /> + + {/* Handles the real-time stream */} + ); diff --git a/src/components/hooks/useUser.ts b/src/components/hooks/useUser.ts index 122cbdb..22014d4 100644 --- a/src/components/hooks/useUser.ts +++ b/src/components/hooks/useUser.ts @@ -1,6 +1,7 @@ import Router from 'next/router'; import { useEffect } from 'react'; import useSWR from 'swr'; +import { UserSession } from '../../types/auth/UserSession'; type Props = { redirectTo?: string; @@ -14,7 +15,7 @@ const fetcher = (url: string) => return { user: data?.user || null }; }); -export const useUser = (props: Props = {}) => { +export const useUser = (props: Props = {}): UserSession | null => { const { redirectTo, redirectIfFound } = props; const { data, error } = useSWR( '/api/user', diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 6e7c780..49ad0a0 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -7,7 +7,6 @@ import { import { Router } from 'next/router'; import React from 'react'; import { RecoilRoot } from 'recoil'; -import { useUser } from '../components/hooks/useUser'; import { RecoilDevtools } from '../components/RecoilDevtools'; import { RecoilExternalStatePortal } from '../components/RecoilExternalStatePortal'; import '../utils/fontAwesome'; @@ -29,7 +28,6 @@ type Props = { */ const App: React.FunctionComponent = (props): JSX.Element => { const { Component, pageProps } = props; - const user = useUser(); return ( diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 9570658..2b93280 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -3,9 +3,6 @@ import { useState } from 'react'; import DisplayOnBrowserMount from '../components/DisplayOnBrowserMount'; import EditorContainer from '../components/editor/EditorContainer'; import Layout from '../components/Layout'; -import { setRecoilExternalState } from '../components/RecoilExternalStatePortal'; -import { startStreamingCanvasDataset } from '../lib/faunadb/faunadbClient'; -import { canvasDatasetSelector } from '../states/canvasDatasetSelector'; import { CanvasDataset } from '../types/CanvasDataset'; export type Props = { @@ -35,12 +32,6 @@ export const getStaticProps = (): { props: Props } => { const IndexPage = (props: any) => { const [canvasDataset, setCanvasDataset] = useState(undefined); - // Used to know when the app is ready to be rendered (when the data have been fetched from DB) - const [isReadyToRender, setIsReadyToRender] = useState(false); - - // Used to avoid starting several streams from the same browser - const [isLoadingDataFromDB, setIsLoadingDataFromDB] = useState(false); - /** * Gets the canvas dataset stored in the browser localstorage and makes it available in the global "window" object. * The window.initialCanvasDataset will be used by the nodes/edges atom during their initialisation. @@ -51,34 +42,29 @@ const IndexPage = (props: any) => { */ if (isBrowser()) { // Initialize the stream (only once) - if (!isLoadingDataFromDB) { - setIsLoadingDataFromDB(true); // Starts the stream between the browser and the FaunaDB using the default canvas document - startStreamingCanvasDataset((canvasDatasetFromDB: CanvasDataset) => { - console.log('canvasDatasetFromDB', canvasDatasetFromDB); - setCanvasDataset(canvasDatasetFromDB); - setIsReadyToRender(true); - }, (canvasDatasetRemotelyUpdated: CanvasDataset) => { - setRecoilExternalState(canvasDatasetSelector, canvasDatasetRemotelyUpdated); - }); - } + // startStreamingCanvasDataset((canvasDatasetFromDB: CanvasDataset) => { + // console.log('canvasDatasetFromDB', canvasDatasetFromDB); + // setCanvasDataset(canvasDatasetFromDB); + // setIsReadyToRender(true); + // }, (canvasDatasetRemotelyUpdated: CanvasDataset) => { + // setRecoilExternalState(canvasDatasetSelector, canvasDatasetRemotelyUpdated); + // }); - if (canvasDataset && setIsReadyToRender) { - window.initialCanvasDataset = canvasDataset; - } + // if (canvasDataset) { + // window.initialCanvasDataset = canvasDataset; + // } } return ( {/* Only renders the EditorContainer on the browser because it's not server-side compatible */} { - isReadyToRender && ( - - ) + } diff --git a/src/types/faunadb/CanvasResult.ts b/src/types/faunadb/CanvasResult.ts new file mode 100644 index 0000000..e70fc52 --- /dev/null +++ b/src/types/faunadb/CanvasResult.ts @@ -0,0 +1,4 @@ +import * as Fauna from 'faunadb/src/types/values'; +import { CanvasDataset } from '../CanvasDataset'; + +export type CanvasResult = Fauna.values.Document; diff --git a/src/types/faunadb/CanvasStream.ts b/src/types/faunadb/CanvasStream.ts new file mode 100644 index 0000000..319952f --- /dev/null +++ b/src/types/faunadb/CanvasStream.ts @@ -0,0 +1,4 @@ +import { CanvasDataset } from '../CanvasDataset'; + +export type OnInit = (canvasDataset: CanvasDataset) => void; +export type OnUpdate = (canvasDataset: CanvasDataset) => void; diff --git a/src/types/faunadb/FaunadbStreamVersionEvent.ts b/src/types/faunadb/FaunadbStreamVersionEvent.ts new file mode 100644 index 0000000..7c4bd90 --- /dev/null +++ b/src/types/faunadb/FaunadbStreamVersionEvent.ts @@ -0,0 +1,8 @@ +import { CanvasResult } from './CanvasResult'; + +export type FaunadbStreamVersionEvent = { + action: 'create' | 'update' | 'delete'; + document: CanvasResult; + diff: CanvasResult; + prev: CanvasResult; +} diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts new file mode 100644 index 0000000..1568915 --- /dev/null +++ b/src/utils/canvasStream.ts @@ -0,0 +1,149 @@ +import { + Client, + Create, + Expr, + Get, + Update, +} from 'faunadb'; +import { Subscription } from 'faunadb/src/types/Stream'; +import isEqual from 'lodash.isequal'; +import { + getClient, + q, +} from '../lib/faunadb/faunadb'; +import { findSharedCanvasDocument } from '../lib/faunadb/faunadbClient'; +import { UserSession } from '../types/auth/UserSession'; +import { CanvasDataset } from '../types/CanvasDataset'; +import { CanvasResult } from '../types/faunadb/CanvasResult'; +import { + OnInit, + OnUpdate, +} from '../types/faunadb/CanvasStream'; +import { FaunadbStreamVersionEvent } from '../types/faunadb/FaunadbStreamVersionEvent'; + +const { Ref, Collection } = q; + +const PUBLIC_SHARED_FAUNABD_TOKEN = 'fnAEDdp0CWACBZUTQvkktsqAQeW03uDhZYY0Ttlg'; +const SHARED_CANVAS_DOCUMENT_ID = '1'; + +export const getUserClient = (user: UserSession | null): Client => { + const secret = user?.faunaDBToken || PUBLIC_SHARED_FAUNABD_TOKEN; + + return getClient(secret); +}; + +/** + * Starts the real-time stream between the browser and the FaunaDB database, on a specific record/document. + * + * @param user + * @param onInit + * @param onUpdate + */ +export const initStream = (user: UserSession | null, onInit: OnInit, onUpdate: OnUpdate) => { + const client: Client = getUserClient(user); + const canvasRef: Expr = findUserCanvasRef(user); + let stream: Subscription; + + const _startStream = async (documentRef: Expr) => { + console.log(`Stream to FaunaDB is starting for:`, canvasRef); + + stream = client.stream. + // @ts-ignore + document(canvasRef) + .on('start', (at: number) => { + console.log('Stream started at:', at); + }) + .on('snapshot', (snapshot: CanvasResult) => { + console.log('snapshot', snapshot); + onInit(snapshot.data); + }) + .on('version', (version: FaunadbStreamVersionEvent) => { + console.log('version', version); + + if (version.action === 'update') { + onUpdate(version.document.data); + } + }) + .on('error', async (error: any) => { + console.log('Error:', error); + if (error?.name === 'NotFound') { + const defaultCanvasDataset: CanvasDataset = { + nodes: [], + edges: [], + }; + + console.log('No record found, creating record...'); + const createDefaultRecord = Create(Ref(Collection('Canvas'), '1'), { + data: defaultCanvasDataset, + }); + const result: CanvasResult = await client.query(createDefaultRecord); + console.log('result', result); + onInit(result?.data); + } else { + stream.close(); + setTimeout(_startStream, 1000); + } + }) + .on('history_rewrite', (error: any) => { + console.log('Error:', error); + stream.close(); + setTimeout(_startStream, 1000); + }) + .start(); + }; + +}; + +/** + * Finds the shared canvas document. + * + * Always use "1" as document ref id. + * There is only one document in the DB, and the same document is shared with all users. + */ +export const findUserCanvasRef = (user: UserSession | null): Expr => { + let userDocumentId: string; + + if (user) { + userDocumentId = ''; // TODO + } else { + userDocumentId = SHARED_CANVAS_DOCUMENT_ID; + } + + return Ref(Collection('Canvas'), userDocumentId); +}; + +/** + * Updates the shared canvas document. + * + * Only update if the content has changed, to avoid infinite loop because this is called during canvas rendering (useEffect) + * + * @param user + * @param newCanvasDataset + */ +export const updateSharedCanvasDocument = async (user: UserSession | null, newCanvasDataset: CanvasDataset) => { + const client: Client = getUserClient(user); + const existingCanvasDatasetResult: CanvasResult = await client.query(Get(findSharedCanvasDocument())); + const existingCanvasDataset: CanvasDataset = existingCanvasDatasetResult.data; + + if (!isEqual(newCanvasDataset, existingCanvasDataset)) { + console.log('Updating canvas dataset in FaunaDB. Old:', existingCanvasDataset, 'new:', newCanvasDataset); + + return client + .query(Update(findSharedCanvasDocument(), { data: newCanvasDataset })) + // @ts-ignore + .then((result: CanvasResult) => { + console.log('FaunaDB Canvas dataset updated', result); + }) + .catch((error: Error) => { + console.error(error); + }); + } +}; + +export const onInit: OnInit = (canvasDataset: CanvasDataset) => { + +} + +export const onUpdate: OnUpdate = (canvasDataset: CanvasDataset) => { + +} From af827fc218cfc4d4b47d0de8fcd49a1c96dac7fc Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 18:54:57 +0100 Subject: [PATCH 017/148] wip --- src/lib/faunadb/faunadbClient.ts | 39 +++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/lib/faunadb/faunadbClient.ts b/src/lib/faunadb/faunadbClient.ts index 458c230..e580af2 100644 --- a/src/lib/faunadb/faunadbClient.ts +++ b/src/lib/faunadb/faunadbClient.ts @@ -1,23 +1,36 @@ +import { isBrowser } from '@unly/utils'; import faunadb, { + Client, Create, Expr, Get, Update, } from 'faunadb'; import { Subscription } from 'faunadb/src/types/Stream'; -import * as Fauna from 'faunadb/src/types/values'; import isEqual from 'lodash.isequal'; import { CanvasDataset } from '../../types/CanvasDataset'; +import { CanvasResult } from '../../types/faunadb/CanvasResult'; +import { + OnInit, + OnUpdate, +} from '../../types/faunadb/CanvasStream'; +import { FaunadbStreamVersionEvent } from '../../types/faunadb/FaunadbStreamVersionEvent'; const { Ref, Collection } = faunadb.query; -const client = new faunadb.Client({ secret: 'fnAEDdp0CWACBZUTQvkktsqAQeW03uDhZYY0Ttlg' }); +let client: Client; -type CanvasResult = Fauna.values.Document; -type VersionEvent = { - action: 'create' | 'update' | 'delete'; - document: CanvasResult; - diff: CanvasResult; - prev: CanvasResult; +// TODO use stream manager +// Convert to react component because we need to use hooks +if (isBrowser()) { + const secret = + // @ts-ignore + window['__FAUNADB_USER_TOKEN__'] + || 'fnAEDdp0CWACBZUTQvkktsqAQeW03uDhZYY0Ttlg'; + + console.log('Initializing stream client with secret', secret); + client = new faunadb.Client({ + secret, + }); } /** @@ -62,11 +75,11 @@ export const updateSharedCanvasDocument = async (newCanvasDataset: CanvasDataset * @param onInit * @param onUpdate */ -export const startStreamingCanvasDataset = (onInit: (canvasDataset: CanvasDataset) => void, onUpdate: (canvasDataset: CanvasDataset) => void) => { +export const startStreamingCanvasDataset = (onInit: OnInit, onUpdate: OnUpdate) => { let stream: Subscription; - const _startStream = async (documentRef: Expr) => { - console.log(`Stream to FaunaDB is starting for:`, documentRef); + const _startStream = async () => { + console.log(`Stream to FaunaDB is starting for:`, findSharedCanvasDocument()); stream = client.stream. // @ts-ignore @@ -78,7 +91,7 @@ export const startStreamingCanvasDataset = (onInit: (canvasDataset: CanvasDatase console.log('snapshot', snapshot); onInit(snapshot.data); }) - .on('version', (version: VersionEvent) => { + .on('version', (version: FaunadbStreamVersionEvent) => { console.log('version', version); if (version.action === 'update') { @@ -113,5 +126,5 @@ export const startStreamingCanvasDataset = (onInit: (canvasDataset: CanvasDatase .start(); }; - _startStream(findSharedCanvasDocument()); + _startStream(); }; From 24aea38f8ccaf91c8fc8af12bc743696b1b7446e Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 18:55:10 +0100 Subject: [PATCH 018/148] Remove old faunadbClient.ts --- src/lib/faunadb/faunadbClient.ts | 130 ------------------------------- src/utils/canvasStream.ts | 9 +-- 2 files changed, 4 insertions(+), 135 deletions(-) delete mode 100644 src/lib/faunadb/faunadbClient.ts diff --git a/src/lib/faunadb/faunadbClient.ts b/src/lib/faunadb/faunadbClient.ts deleted file mode 100644 index e580af2..0000000 --- a/src/lib/faunadb/faunadbClient.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { isBrowser } from '@unly/utils'; -import faunadb, { - Client, - Create, - Expr, - Get, - Update, -} from 'faunadb'; -import { Subscription } from 'faunadb/src/types/Stream'; -import isEqual from 'lodash.isequal'; -import { CanvasDataset } from '../../types/CanvasDataset'; -import { CanvasResult } from '../../types/faunadb/CanvasResult'; -import { - OnInit, - OnUpdate, -} from '../../types/faunadb/CanvasStream'; -import { FaunadbStreamVersionEvent } from '../../types/faunadb/FaunadbStreamVersionEvent'; - -const { Ref, Collection } = faunadb.query; -let client: Client; - -// TODO use stream manager -// Convert to react component because we need to use hooks -if (isBrowser()) { - const secret = - // @ts-ignore - window['__FAUNADB_USER_TOKEN__'] - || 'fnAEDdp0CWACBZUTQvkktsqAQeW03uDhZYY0Ttlg'; - - console.log('Initializing stream client with secret', secret); - client = new faunadb.Client({ - secret, - }); -} - -/** - * Finds the shared canvas document. - * - * Always use "1" as document ref id. - * There is only one document in the DB, and the same document is shared with all users. - */ -export const findSharedCanvasDocument = (id: string = '1') => { - return Ref(Collection('Canvas'), id); -}; - -/** - * Updates the shared canvas document. - * - * Only update if the content has changed, to avoid infinite loop because this is called during canvas rendering (useEffect) - * - * @param newCanvasDataset - */ -export const updateSharedCanvasDocument = async (newCanvasDataset: CanvasDataset) => { - const existingCanvasDatasetResult: CanvasResult = await client.query(Get(findSharedCanvasDocument())); - const existingCanvasDataset: CanvasDataset = existingCanvasDatasetResult.data; - - if (!isEqual(newCanvasDataset, existingCanvasDataset)) { - console.log('Updating canvas dataset in FaunaDB. Old:', existingCanvasDataset, 'new:', newCanvasDataset); - - return client - .query(Update(findSharedCanvasDocument(), { data: newCanvasDataset })) - // @ts-ignore - .then((result: CanvasResult) => { - console.log('FaunaDB Canvas dataset updated', result); - }) - .catch((error: Error) => { - console.error(error); - }); - } -}; - -/** - * Starts the real-time stream between the browser and the FaunaDB database, on a specific record/document. - * - * @param onInit - * @param onUpdate - */ -export const startStreamingCanvasDataset = (onInit: OnInit, onUpdate: OnUpdate) => { - let stream: Subscription; - - const _startStream = async () => { - console.log(`Stream to FaunaDB is starting for:`, findSharedCanvasDocument()); - - stream = client.stream. - // @ts-ignore - document(documentRef) - .on('start', (at: number) => { - console.log('Stream started at:', at); - }) - .on('snapshot', (snapshot: CanvasResult) => { - console.log('snapshot', snapshot); - onInit(snapshot.data); - }) - .on('version', (version: FaunadbStreamVersionEvent) => { - console.log('version', version); - - if (version.action === 'update') { - onUpdate(version.document.data); - } - }) - .on('error', async (error: any) => { - console.log('Error:', error); - if (error?.name === 'NotFound') { - const defaultCanvasDataset: CanvasDataset = { - nodes: [], - edges: [], - }; - - console.log('No record found, creating record...'); - const createDefaultRecord = Create(Ref(Collection('Canvas'), '1'), { - data: defaultCanvasDataset, - }); - const result: CanvasResult = await client.query(createDefaultRecord); - console.log('result', result); - onInit(result?.data); - } else { - stream.close(); - setTimeout(_startStream, 1000); - } - }) - .on('history_rewrite', (error: any) => { - console.log('Error:', error); - stream.close(); - setTimeout(_startStream, 1000); - }) - .start(); - }; - - _startStream(); -}; diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 1568915..cdccb37 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -11,7 +11,6 @@ import { getClient, q, } from '../lib/faunadb/faunadb'; -import { findSharedCanvasDocument } from '../lib/faunadb/faunadbClient'; import { UserSession } from '../types/auth/UserSession'; import { CanvasDataset } from '../types/CanvasDataset'; import { CanvasResult } from '../types/faunadb/CanvasResult'; @@ -122,14 +121,14 @@ export const findUserCanvasRef = (user: UserSession | null): Expr => { */ export const updateSharedCanvasDocument = async (user: UserSession | null, newCanvasDataset: CanvasDataset) => { const client: Client = getUserClient(user); - const existingCanvasDatasetResult: CanvasResult = await client.query(Get(findSharedCanvasDocument())); + const existingCanvasDatasetResult: CanvasResult = await client.query(Get(findUserCanvasRef(user))); const existingCanvasDataset: CanvasDataset = existingCanvasDatasetResult.data; if (!isEqual(newCanvasDataset, existingCanvasDataset)) { console.log('Updating canvas dataset in FaunaDB. Old:', existingCanvasDataset, 'new:', newCanvasDataset); return client - .query(Update(findSharedCanvasDocument(), { data: newCanvasDataset })) + .query(Update(findUserCanvasRef(user), { data: newCanvasDataset })) // @ts-ignore .then((result: CanvasResult) => { console.log('FaunaDB Canvas dataset updated', result); @@ -142,8 +141,8 @@ export const updateSharedCanvasDocument = async (user: UserSession | null, newCa export const onInit: OnInit = (canvasDataset: CanvasDataset) => { -} +}; export const onUpdate: OnUpdate = (canvasDataset: CanvasDataset) => { -} +}; From 6c4fa72b6f1b8514422c546633c1fd61208f55b7 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 19:06:55 +0100 Subject: [PATCH 019/148] Rename "users" collection > Users --- fql/setup.js | 5 +++-- src/lib/faunadb/models/userModel.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/fql/setup.js b/fql/setup.js index 003f29f..9d27b71 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -1,10 +1,11 @@ // Step 1: Create a "users" collection -CreateCollection({ name: "users" }); +CreateCollection({ name: "Users" }); +CreateCollection({ name: "Canvas" }); // Step 3: Create all relevant Indexes CreateIndex({ name: "users_by_email", - source: Collection("users"), + source: Collection("Users"), terms: [{ field: ["data", "email"] }], unique: true }); diff --git a/src/lib/faunadb/models/userModel.ts b/src/lib/faunadb/models/userModel.ts index ff095a2..608b5db 100644 --- a/src/lib/faunadb/models/userModel.ts +++ b/src/lib/faunadb/models/userModel.ts @@ -25,7 +25,7 @@ export class UserModel { * @param email */ async createUser(email: string): Promise { - return faunadbAdminClient?.query(Create(Collection('users'), { + return faunadbAdminClient?.query(Create(Collection('Users'), { data: { email }, })); } From 208190b6ff5a7a815d660ae224607b7f306fb27e Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 10 Mar 2021 19:16:22 +0100 Subject: [PATCH 020/148] Fix streaming for guest users --- src/components/FaunaDBCanvasStream.tsx | 2 -- src/components/editor/CanvasContainer.tsx | 22 +++++++++++++++++++--- src/pages/index.tsx | 11 ----------- src/types/faunadb/CanvasStream.ts | 2 +- src/utils/canvasStream.ts | 16 +++++++++++----- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/components/FaunaDBCanvasStream.tsx b/src/components/FaunaDBCanvasStream.tsx index 1e8b864..ef931a7 100644 --- a/src/components/FaunaDBCanvasStream.tsx +++ b/src/components/FaunaDBCanvasStream.tsx @@ -27,9 +27,7 @@ const FaunaDBCanvasStream: React.FunctionComponent = (props) => { // Used to avoid starting several streams from the same browser const [hasStreamStarted, setHasStreamStarted] = useState(false); - const user: UserSession | null = useUser(); - console.log('FaunaDBCanvasStream user', user); if (!isBrowser()) { return null; diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index 18cc8b8..175f8bb 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -23,10 +23,13 @@ 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 { onInit, onUpdate, + updateSharedCanvasDocument, } from '../../utils/canvasStream'; import { isOlderThan } from '../../utils/date'; import { @@ -36,6 +39,7 @@ import { import canvasUtilsContext from '../context/canvasUtilsContext'; import BaseEdge from '../edges/BaseEdge'; import FaunaDBCanvasStream from '../FaunaDBCanvasStream'; +import { useUser } from '../hooks/useUser'; import NodeRouter from '../nodes/NodeRouter'; type Props = { @@ -62,6 +66,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n const { canvasRef, } = props; + const user: UserSession | null = useUser(); /** * The canvas ref contains useful properties (xy, scroll, etc.) and functions (zoom, centerCanvas, etc.) @@ -82,6 +87,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n 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); /** * When nodes or edges are modified, updates the persisted data in FaunaDB. @@ -90,7 +96,11 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n */ 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) { + updateSharedCanvasDocument(user, canvasDataset); + } }, [canvasDataset]); /** @@ -126,11 +136,12 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n * Ensures the start node is always present. * * Will automatically create the start node even if all the nodes are 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 startNode: BaseNodeData | undefined = nodes?.find((node: BaseNodeData) => node?.data?.type === 'start'); - if (!startNode) { + if (!startNode && isStreaming) { console.info(`No "start" node found. Creating one automatically.`, nodes); setNodes([ ...nodes, @@ -381,7 +392,12 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n {/* Handles the real-time stream */} { + onInit(canvasDataset); + + // Mark the stream has running + setIsStreaming(true); + }} onUpdate={onUpdate} /> diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 2b93280..e493444 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -41,17 +41,6 @@ const IndexPage = (props: any) => { * Also, it's a viable approach whether using the data from browser localstorage, or a real DB. */ if (isBrowser()) { - // Initialize the stream (only once) - - // Starts the stream between the browser and the FaunaDB using the default canvas document - // startStreamingCanvasDataset((canvasDatasetFromDB: CanvasDataset) => { - // console.log('canvasDatasetFromDB', canvasDatasetFromDB); - // setCanvasDataset(canvasDatasetFromDB); - // setIsReadyToRender(true); - // }, (canvasDatasetRemotelyUpdated: CanvasDataset) => { - // setRecoilExternalState(canvasDatasetSelector, canvasDatasetRemotelyUpdated); - // }); - // if (canvasDataset) { // window.initialCanvasDataset = canvasDataset; // } diff --git a/src/types/faunadb/CanvasStream.ts b/src/types/faunadb/CanvasStream.ts index 319952f..25b4adc 100644 --- a/src/types/faunadb/CanvasStream.ts +++ b/src/types/faunadb/CanvasStream.ts @@ -1,4 +1,4 @@ import { CanvasDataset } from '../CanvasDataset'; export type OnInit = (canvasDataset: CanvasDataset) => void; -export type OnUpdate = (canvasDataset: CanvasDataset) => void; +export type OnUpdate = (canvasDatasetRemotelyUpdated: CanvasDataset) => void; diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index cdccb37..d5f8aba 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -7,10 +7,12 @@ import { } from 'faunadb'; import { Subscription } from 'faunadb/src/types/Stream'; import isEqual from 'lodash.isequal'; +import { setRecoilExternalState } from '../components/RecoilExternalStatePortal'; import { getClient, q, } from '../lib/faunadb/faunadb'; +import { canvasDatasetSelector } from '../states/canvasDatasetSelector'; import { UserSession } from '../types/auth/UserSession'; import { CanvasDataset } from '../types/CanvasDataset'; import { CanvasResult } from '../types/faunadb/CanvasResult'; @@ -41,10 +43,11 @@ export const getUserClient = (user: UserSession | null): Client => { export const initStream = (user: UserSession | null, onInit: OnInit, onUpdate: OnUpdate) => { const client: Client = getUserClient(user); const canvasRef: Expr = findUserCanvasRef(user); + console.log('Working on Canvas document', canvasRef); let stream: Subscription; - const _startStream = async (documentRef: Expr) => { - console.log(`Stream to FaunaDB is starting for:`, canvasRef); + const _startStream = async () => { + console.log(`Stream to FaunaDB is (re)starting for:`, canvasRef); stream = client.stream. // @ts-ignore @@ -91,6 +94,7 @@ export const initStream = (user: UserSession | null, onInit: OnInit, onUpdate: O .start(); }; + _startStream(); }; /** @@ -140,9 +144,11 @@ export const updateSharedCanvasDocument = async (user: UserSession | null, newCa }; export const onInit: OnInit = (canvasDataset: CanvasDataset) => { - + // Starts the stream between the browser and the FaunaDB using the default canvas document + console.log('onInit canvasDataset', canvasDataset); + setRecoilExternalState(canvasDatasetSelector, canvasDataset); }; -export const onUpdate: OnUpdate = (canvasDataset: CanvasDataset) => { - +export const onUpdate: OnUpdate = (canvasDatasetRemotelyUpdated: CanvasDataset) => { + setRecoilExternalState(canvasDatasetSelector, canvasDatasetRemotelyUpdated); }; From 557360f47cfd7e0f3eb0fc76c1d0d25e2ed63ddc Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 00:23:55 +0100 Subject: [PATCH 021/148] Automatically create a new Canvas document for authenticated users or load existing document (WIP, overwrite existing document with current) --- fql/examples.js | 15 ++ fql/setup.js | 60 +++++- src/components/editor/CanvasContainer.tsx | 4 +- src/lib/faunadb/faunadb.ts | 5 + src/types/faunadb/Canvas.ts | 8 + src/types/faunadb/CanvasByOwnerIndex.ts | 13 ++ src/types/faunadb/FaunadbBaseFields.ts | 4 +- src/utils/canvasStream.ts | 222 ++++++++++++++-------- src/utils/fql.ts | 22 +++ 9 files changed, 260 insertions(+), 93 deletions(-) create mode 100644 fql/examples.js create mode 100644 src/types/faunadb/Canvas.ts create mode 100644 src/types/faunadb/CanvasByOwnerIndex.ts create mode 100644 src/utils/fql.ts 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 index 9d27b71..88bf72e 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -1,11 +1,55 @@ -// Step 1: Create a "users" collection -CreateCollection({ name: "Users" }); -CreateCollection({ name: "Canvas" }); +// Step 1: Create a "Users" collection +CreateCollection({ name: 'Users' }); -// Step 3: Create all relevant Indexes +// Step 2: Create "Canvas" collection +CreateCollection({ name: 'Canvas' }); + +// Step 3: Create indexes +CreateIndex({ + name: 'users_by_email', + source: Collection('Users'), + terms: [{ field: ['data', 'email'] }], + unique: true, +}); CreateIndex({ - name: "users_by_email", - source: Collection("Users"), - terms: [{ field: ["data", "email"] }], - unique: true + name: 'canvas_by_owner', + source: Collection('Canvas'), + permissions: { read: Collection("Users") }, + terms: [{ field: ['data', 'owner'] }], + values: [ + { field: ['ref'] }, + { field: ['data', 'nodes'] }, + { field: ['data', 'edges'] }, + ], +}); + +// Step 4: Create roles +// All users should be editors. +// An editor should be able to edit only Canvas documents that belongs to them. +CreateRole({ + name: 'Editor', + membership: { + resource: Collection('Users'), + }, + privileges: [ + { + resource: Index('canvas_by_owner'), + actions: { + read: true, + }, + }, + { + resource: Collection('Canvas'), + actions: { + read: Query( + Lambda("ref", Equals( + CurrentIdentity(), + Select(["data", "owner"], Get(Var("ref"))) + )) + ), + write: true, + create: true, + }, + }, + ], }); diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index 175f8bb..bcfd1de 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -29,7 +29,7 @@ import { CanvasDataset } from '../../types/CanvasDataset'; import { onInit, onUpdate, - updateSharedCanvasDocument, + updateUserCanvas, } from '../../utils/canvasStream'; import { isOlderThan } from '../../utils/date'; import { @@ -99,7 +99,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n // Only save changes once the stream has started, to avoid saving anything until the initial canvas dataset was initialized if (isStreaming) { - updateSharedCanvasDocument(user, canvasDataset); + updateUserCanvas(user, canvasDataset); } }, [canvasDataset]); diff --git a/src/lib/faunadb/faunadb.ts b/src/lib/faunadb/faunadb.ts index 219d1c0..76f0783 100644 --- a/src/lib/faunadb/faunadb.ts +++ b/src/lib/faunadb/faunadb.ts @@ -19,6 +19,11 @@ export const q = faunadb.query; export function getClient(secret: string) { const options: ClientConfig = { secret, + + // Custom observer used to log responses for easier debug + observer: (res, client) => { + console.debug('FaunaDB response:', res); + }, }; return new faunadb.Client(options); diff --git a/src/types/faunadb/Canvas.ts b/src/types/faunadb/Canvas.ts new file mode 100644 index 0000000..278975d --- /dev/null +++ b/src/types/faunadb/Canvas.ts @@ -0,0 +1,8 @@ +import { Expr } from 'faunadb'; +import { FaunadbRecordBaseFields } from './FaunadbRecordBaseFields'; + +export type Canvas = FaunadbRecordBaseFields<{ + owner: Expr; + nodes: object[]; + edges: object[]; +}> diff --git a/src/types/faunadb/CanvasByOwnerIndex.ts b/src/types/faunadb/CanvasByOwnerIndex.ts new file mode 100644 index 0000000..14e566f --- /dev/null +++ b/src/types/faunadb/CanvasByOwnerIndex.ts @@ -0,0 +1,13 @@ +import { values } from 'faunadb'; +import BaseEdgeData from '../BaseEdgeData'; +import BaseNodeData from '../BaseNodeData'; + +export type CanvasByOwnerIndexData = [ + values.Ref, + BaseNodeData[], + BaseEdgeData[], +]; + +export type CanvasByOwnerIndex = { + data: CanvasByOwnerIndexData[]; +}; diff --git a/src/types/faunadb/FaunadbBaseFields.ts b/src/types/faunadb/FaunadbBaseFields.ts index 637708a..6d0f8fe 100644 --- a/src/types/faunadb/FaunadbBaseFields.ts +++ b/src/types/faunadb/FaunadbBaseFields.ts @@ -1,4 +1,4 @@ export type FaunadbBaseFields = { - ref: any; - ts: number; + ref?: any; + ts?: number; } diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index d5f8aba..901e154 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -1,20 +1,24 @@ import { Client, + Collection, Create, Expr, Get, + Index, + Match, + Paginate, + Ref, Update, } from 'faunadb'; import { Subscription } from 'faunadb/src/types/Stream'; import isEqual from 'lodash.isequal'; import { setRecoilExternalState } from '../components/RecoilExternalStatePortal'; -import { - getClient, - q, -} from '../lib/faunadb/faunadb'; +import { getClient } from '../lib/faunadb/faunadb'; import { canvasDatasetSelector } from '../states/canvasDatasetSelector'; import { UserSession } from '../types/auth/UserSession'; import { CanvasDataset } from '../types/CanvasDataset'; +import { Canvas } from '../types/faunadb/Canvas'; +import { CanvasByOwnerIndex } from '../types/faunadb/CanvasByOwnerIndex'; import { CanvasResult } from '../types/faunadb/CanvasResult'; import { OnInit, @@ -22,8 +26,6 @@ import { } from '../types/faunadb/CanvasStream'; import { FaunadbStreamVersionEvent } from '../types/faunadb/FaunadbStreamVersionEvent'; -const { Ref, Collection } = q; - const PUBLIC_SHARED_FAUNABD_TOKEN = 'fnAEDdp0CWACBZUTQvkktsqAQeW03uDhZYY0Ttlg'; const SHARED_CANVAS_DOCUMENT_ID = '1'; @@ -40,61 +42,66 @@ export const getUserClient = (user: UserSession | null): Client => { * @param onInit * @param onUpdate */ -export const initStream = (user: UserSession | null, onInit: OnInit, onUpdate: OnUpdate) => { +export const initStream = async (user: UserSession | null, onInit: OnInit, onUpdate: OnUpdate) => { const client: Client = getUserClient(user); - const canvasRef: Expr = findUserCanvasRef(user); - console.log('Working on Canvas document', canvasRef); - let stream: Subscription; - - const _startStream = async () => { - console.log(`Stream to FaunaDB is (re)starting for:`, canvasRef); - - stream = client.stream. - // @ts-ignore - document(canvasRef) - .on('start', (at: number) => { - console.log('Stream started at:', at); - }) - .on('snapshot', (snapshot: CanvasResult) => { - console.log('snapshot', snapshot); - onInit(snapshot.data); - }) - .on('version', (version: FaunadbStreamVersionEvent) => { - console.log('version', version); - - if (version.action === 'update') { - onUpdate(version.document.data); - } - }) - .on('error', async (error: any) => { - console.log('Error:', error); - if (error?.name === 'NotFound') { - const defaultCanvasDataset: CanvasDataset = { - nodes: [], - edges: [], - }; - - console.log('No record found, creating record...'); - const createDefaultRecord = Create(Ref(Collection('Canvas'), '1'), { - data: defaultCanvasDataset, - }); - const result: CanvasResult = await client.query(createDefaultRecord); - console.log('result', result); - onInit(result?.data); - } else { + const canvasRef: Expr | undefined = await findUserCanvasRef(user); + + if (canvasRef) { + console.log('Working on Canvas document', canvasRef); + let stream: Subscription; + + const _startStream = async () => { + console.log(`Stream to FaunaDB is (re)starting for:`, canvasRef); + + stream = client.stream. + // @ts-ignore + document(canvasRef) + .on('start', (at: number) => { + console.log('Stream started at:', at); + }) + .on('snapshot', (snapshot: CanvasResult) => { + console.log('snapshot', snapshot); + onInit(snapshot.data); + }) + .on('version', (version: FaunadbStreamVersionEvent) => { + console.log('version', version); + + if (version.action === 'update') { + onUpdate(version.document.data); + } + }) + .on('error', async (error: any) => { + console.log('Error:', error); + if (error?.name === 'NotFound') { + const defaultCanvasDataset: CanvasDataset = { + nodes: [], + edges: [], + }; + + console.log('No record found, creating record...'); + const createDefaultRecord = Create(findUserCanvasRef(user), { + data: defaultCanvasDataset, + }); + const result: CanvasResult = await client.query(createDefaultRecord); + console.log('result', result); + onInit(result?.data); + } else { + stream.close(); + setTimeout(_startStream, 1000); + } + }) + .on('history_rewrite', (error: any) => { + console.log('Error:', error); stream.close(); setTimeout(_startStream, 1000); - } - }) - .on('history_rewrite', (error: any) => { - console.log('Error:', error); - stream.close(); - setTimeout(_startStream, 1000); - }) - .start(); - }; - - _startStream(); + }) + .start(); + }; + + _startStream(); + } else { + console.error(`[initStream] "canvasRef" is undefined, streaming aborted.`, canvasRef); + } }; /** @@ -103,43 +110,96 @@ export const initStream = (user: UserSession | null, onInit: OnInit, onUpdate: O * Always use "1" as document ref id. * There is only one document in the DB, and the same document is shared with all users. */ -export const findUserCanvasRef = (user: UserSession | null): Expr => { - let userDocumentId: string; - +export const findUserCanvasRef = async (user: UserSession | null): Promise => { if (user) { - userDocumentId = ''; // TODO + return await findOrCreateUserCanvas(user); } else { - userDocumentId = SHARED_CANVAS_DOCUMENT_ID; + return Ref(Collection('Canvas'), SHARED_CANVAS_DOCUMENT_ID); } +}; - return Ref(Collection('Canvas'), userDocumentId); +export const findOrCreateUserCanvas = async (user: UserSession): Promise => { + console.log('findOrCreateUserCanvas'); + const client: Client = getUserClient(user); + const findUserCanvas = Paginate( + Match( + Index('canvas_by_owner'), + Ref(Collection('Users'), '292674252603130373'), + ), + ); + + try { + const findUserCanvasResult = await client.query(findUserCanvas); + console.log('findUserCanvasResult', findUserCanvasResult); + + if (findUserCanvasResult?.data?.length === 0) { + // This user doesn't have a Canvas document yet + const canvas: Canvas = { + data: { + owner: Ref(Collection('Users'), '292674252603130373'), + nodes: [], + edges: [], + }, + }; + + const createUserCanvas = Create( + Collection('Canvas'), + canvas, + ); + + try { + const createUserCanvasResult = await client.query(createUserCanvas); + console.log('createUserCanvasResult', createUserCanvasResult); + + return createUserCanvasResult?.ref; + } catch (e) { + console.error(`[findOrCreateUserCanvas] Error while creating canvas:`, e); + } + } else { + // Return existing canvas reference + // Although users could have several canvas (projects), they can only create one and thus we only care about the first + const [canvasRef, nodes, edges] = findUserCanvasResult.data[0]; + return canvasRef; + } + } catch (e) { + console.error(`[findOrCreateUserCanvas] Error while fetching canvas:`, e); + } }; /** - * Updates the shared canvas document. + * Updates the user canvas document. * * Only update if the content has changed, to avoid infinite loop because this is called during canvas rendering (useEffect) * * @param user * @param newCanvasDataset */ -export const updateSharedCanvasDocument = async (user: UserSession | null, newCanvasDataset: CanvasDataset) => { +export const updateUserCanvas = async (user: UserSession | null, newCanvasDataset: CanvasDataset): Promise => { const client: Client = getUserClient(user); - const existingCanvasDatasetResult: CanvasResult = await client.query(Get(findUserCanvasRef(user))); - const existingCanvasDataset: CanvasDataset = existingCanvasDatasetResult.data; - - if (!isEqual(newCanvasDataset, existingCanvasDataset)) { - console.log('Updating canvas dataset in FaunaDB. Old:', existingCanvasDataset, 'new:', newCanvasDataset); - - return client - .query(Update(findUserCanvasRef(user), { data: newCanvasDataset })) - // @ts-ignore - .then((result: CanvasResult) => { - console.log('FaunaDB Canvas dataset updated', result); - }) - .catch((error: Error) => { - console.error(error); - }); + + try { + const canvasRef = await findUserCanvasRef(user); + + if (canvasRef) { + const existingCanvasDatasetResult: CanvasResult = await client.query(Get(canvasRef)); + const existingCanvasDataset: CanvasDataset = existingCanvasDatasetResult.data; + + if (!isEqual(newCanvasDataset, existingCanvasDataset)) { + console.log('Updating canvas dataset in FaunaDB. Old:', existingCanvasDataset, 'new:', newCanvasDataset); + + try { + const x: CanvasResult = await client.query(Update(canvasRef, { data: newCanvasDataset })); + console.log('x', x); + + } catch (e) { + console.error(`[updateUserCanvas] Error while updating canvas:`, e); + } + } + } else { + console.error(`[updateUserCanvas] "canvasRef" is undefined, update aborted.`, canvasRef); + } + } catch (e) { + console.error(`[updateUserCanvas] Error while fetching canvas:`, e); } }; diff --git a/src/utils/fql.ts b/src/utils/fql.ts new file mode 100644 index 0000000..e81c56f --- /dev/null +++ b/src/utils/fql.ts @@ -0,0 +1,22 @@ +import { ExprArg } from 'faunadb'; +import { q } from '../lib/faunadb/faunadb'; + +const { Exists, If, Delete, Update, CreateFunction, CreateRole, Role } = q; + +// Inspiration from https://github.com/fauna-brecht/faunadb-auth-skeleton-frontend/blob/default/fauna-queries/helpers/fql.js + +export const DeleteIfExists = (ref: ExprArg) => If(Exists(ref), false, Delete(ref)); + +export const IfNotExists = (ref: ExprArg, then: ExprArg) => If(Exists(ref), false, then); + +export const CreateOrUpdateFunction = (obj: any) => If( + Exists(q.Function(obj.name)), + Update(q.Function(obj.name), { body: obj.body, role: obj.role }), + CreateFunction({ name: obj.name, body: obj.body, role: obj.role }), +); + +export const CreateOrUpdateRole = (obj: any) => If( + Exists(Role(obj.name)), + Update(Role(obj.name), { membership: obj.membership, privileges: obj.privileges }), + CreateRole(obj), +); From a409d29364c57cb8fca5487bbc22e889def8935b Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 00:35:47 +0100 Subject: [PATCH 022/148] Better TS defs + doc --- fql/setup.js | 39 ++++++++++++++++++------- src/types/faunadb/CanvasByOwnerIndex.ts | 4 --- src/utils/canvasStream.ts | 4 +-- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/fql/setup.js b/fql/setup.js index 88bf72e..815fea9 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -5,34 +5,44 @@ CreateCollection({ name: 'Users' }); 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: 'users_by_email', source: Collection('Users'), - terms: [{ field: ['data', 'email'] }], + 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: 'canvas_by_owner', source: Collection('Canvas'), - permissions: { read: Collection("Users") }, - terms: [{ field: ['data', 'owner'] }], + // 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'] }, - { field: ['data', 'nodes'] }, - { field: ['data', 'edges'] }, ], }); // Step 4: Create roles -// All users should be editors. -// An editor should be able to edit only Canvas documents that belongs to them. 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 canvas_by_owner index to find their own canvas resource: Index('canvas_by_owner'), actions: { read: true, @@ -41,13 +51,22 @@ CreateRole({ { resource: Collection('Canvas'), actions: { + // Editors should be able to read (+ history) only Canvas documents that belongs to them. read: Query( - Lambda("ref", Equals( + Lambda('ref', Equals( + CurrentIdentity(), + Select(['data', 'owner'], Get(Var('ref'))), + )), + ), + history_read: Query( + Lambda('ref', Equals( CurrentIdentity(), - Select(["data", "owner"], Get(Var("ref"))) - )) + Select(['data', 'owner'], Get(Var('ref'))), + )), ), + // Editors should be able to edit only Canvas documents that belongs to them. write: true, + // Editors should be able to create only Canvas documents that belongs to them. create: true, }, }, diff --git a/src/types/faunadb/CanvasByOwnerIndex.ts b/src/types/faunadb/CanvasByOwnerIndex.ts index 14e566f..4e74287 100644 --- a/src/types/faunadb/CanvasByOwnerIndex.ts +++ b/src/types/faunadb/CanvasByOwnerIndex.ts @@ -1,11 +1,7 @@ import { values } from 'faunadb'; -import BaseEdgeData from '../BaseEdgeData'; -import BaseNodeData from '../BaseNodeData'; export type CanvasByOwnerIndexData = [ values.Ref, - BaseNodeData[], - BaseEdgeData[], ]; export type CanvasByOwnerIndex = { diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 901e154..a23256a 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -129,7 +129,7 @@ export const findOrCreateUserCanvas = async (user: UserSession): Promise(findUserCanvas); + const findUserCanvasResult: CanvasByOwnerIndex = await client.query(findUserCanvas); console.log('findUserCanvasResult', findUserCanvasResult); if (findUserCanvasResult?.data?.length === 0) { @@ -158,7 +158,7 @@ export const findOrCreateUserCanvas = async (user: UserSession): Promise Date: Thu, 11 Mar 2021 01:07:32 +0100 Subject: [PATCH 023/148] Remove variants (don't want to maintain old branches) --- README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 195be26..909836c 100644 --- a/README.md +++ b/README.md @@ -43,27 +43,29 @@ Known limitations: ## Variants -While working on this project, I've reached several milestones with a different set of features, in order: +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/)): +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. 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/)): + ([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. _(Current)_ [`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/)): +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 when **anonymous**. - Authenticated users have their own private version of the document, which is protected. + 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. ## Getting started - `yarn` - `yarn start` +- `cp .env.local.example .env.local`, and define your environment variables - Open browser at [http://localhost:8890](http://localhost:8890) ## Deploy your own From dfc54aaab824ad27d3e810f2dff86d6519a7b098 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 01:32:59 +0100 Subject: [PATCH 024/148] Add public role --- fql/setup.js | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/fql/setup.js b/fql/setup.js index 815fea9..dd5da7a 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -64,9 +64,34 @@ CreateRole({ Select(['data', 'owner'], Get(Var('ref'))), )), ), - // Editors should be able to edit only Canvas documents that belongs to them. + // Editors should be able to edit only Canvas documents that belongs to them (but I don't know how to write that). write: true, - // Editors should be able to create only Canvas documents that belongs to them. + // Editors should be able to create only Canvas documents that belongs to them (but I don't know how to write that). + create: true, + }, + }, + ], +}); + +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'], Get(Var('ref'))), + )), + ), + // Guests should only be allowed to update the Canvas of id "1" (but I don't know how to write that) + write: true, + // Guests should only be allowed to create the Canvas of id "1", but this requires admin permissions and will fail + // See https://fauna-community.slack.com/archives/CAKNYCHCM/p1615413941454700 create: true, }, }, From e9fdd3dd45ef1308786578afe573d2d49e412297 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 01:44:39 +0100 Subject: [PATCH 025/148] Add NEXT_PUBLIC_SHARED_FAUNABD_TOKEN env var --- .env.local.example | 7 ++++++- README.md | 1 + src/utils/canvasStream.ts | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.env.local.example b/.env.local.example index dd56698..a3be955 100644 --- a/.env.local.example +++ b/.env.local.example @@ -4,7 +4,12 @@ # Magic Link provides a "publishable key" which is used on the browser (and thus, public). # Go to https://dashboard.magic.link/ > API Keys > Test "Publishable key" -NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk_test_C156E7D8B3720D75 +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" diff --git a/README.md b/README.md index 909836c..13f4536 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ While working on this project, I've reached several milestones with a different - `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) diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index a23256a..8d8f227 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -26,7 +26,7 @@ import { } from '../types/faunadb/CanvasStream'; import { FaunadbStreamVersionEvent } from '../types/faunadb/FaunadbStreamVersionEvent'; -const PUBLIC_SHARED_FAUNABD_TOKEN = 'fnAEDdp0CWACBZUTQvkktsqAQeW03uDhZYY0Ttlg'; +const PUBLIC_SHARED_FAUNABD_TOKEN = process.env.NEXT_PUBLIC_SHARED_FAUNABD_TOKEN as string; const SHARED_CANVAS_DOCUMENT_ID = '1'; export const getUserClient = (user: UserSession | null): Client => { From a895d701cbf6ad62157782f4b7c2fd2f1aead41b Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 12:55:03 +0100 Subject: [PATCH 026/148] Improve TS defs + doc for user API --- src/components/hooks/useUser.ts | 37 +++++++++++++++++++++++++++------ src/pages/api/user.ts | 15 ++++++++----- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/components/hooks/useUser.ts b/src/components/hooks/useUser.ts index 22014d4..521ad80 100644 --- a/src/components/hooks/useUser.ts +++ b/src/components/hooks/useUser.ts @@ -1,6 +1,7 @@ import Router from 'next/router'; import { useEffect } from 'react'; import useSWR from 'swr'; +import { ApiGetUserResult } from '../../pages/api/user'; import { UserSession } from '../../types/auth/UserSession'; type Props = { @@ -8,16 +9,38 @@ type Props = { redirectIfFound?: boolean; } -const fetcher = (url: string) => +/** + * The fetcher is an async function that accepts the key of SWR, and returns the data. + * + * @param url + * + * @see https://swr.vercel.app/docs/data-fetching + */ +const fetcher = (url: string): Promise => fetch(url) .then((r) => r.json()) - .then((data) => { - return { user: data?.user || null }; + .then((data: ApiGetUserResult) => { + return { + user: data?.user || null + }; }); -export const useUser = (props: Props = {}): UserSession | null => { - const { redirectTo, redirectIfFound } = props; - const { data, error } = useSWR( +/** + * Fetches the current user from our internal /api/user and returns it. + * + * The user might not be authenticated, which in this case will return "null". + * The query might not be done, which in this case will return "undefined". + * + * @param props + * + * @see https://swr.vercel.app/ + */ +export const useUser = (props?: Props): UserSession | null | undefined => { + const { redirectTo, redirectIfFound } = props || {}; + const { + data, + error, + } = useSWR( '/api/user', fetcher, { @@ -25,6 +48,7 @@ export const useUser = (props: Props = {}): UserSession | null => { refreshInterval: 1000, }, ); + const isLoading = !error && !data; const user = data?.user; const hasUser = Boolean(user); @@ -45,5 +69,6 @@ export const useUser = (props: Props = {}): UserSession | null => { console.error(error); } + // "user" might be "undefined" or an instance of "UserSession" return error ? null : user; }; diff --git a/src/pages/api/user.ts b/src/pages/api/user.ts index 76c1e70..7da3f52 100644 --- a/src/pages/api/user.ts +++ b/src/pages/api/user.ts @@ -5,6 +5,10 @@ import { import { getUserSession } from '../../lib/auth/userSession'; import { UserSession } from '../../types/auth/UserSession'; +export type ApiGetUserResult = { + user: UserSession | null; +} + type EndpointRequest = NextApiRequest & { query: {}; }; @@ -19,11 +23,12 @@ type EndpointRequest = NextApiRequest & { */ export const user = async (req: EndpointRequest, res: NextApiResponse): Promise => { const userSession: UserSession | undefined = await getUserSession(req); + // TODO fetch user's data + const result: ApiGetUserResult = { + user: userSession || null, + }; - // After getting the session you may want to fetch for the user instead - // of sending the session's payload directly, this example doesn't have a DB - // so it won't matter in this case - res.status(200).json({ user: userSession || null }); -} + res.status(200).json(result); +}; export default user; From 8bef36925757ced239f49d9fa7fd615ab7a8ea3c Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 15:14:24 +0100 Subject: [PATCH 027/148] Add SelectAll utility --- src/utils/fql.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/utils/fql.ts b/src/utils/fql.ts index e81c56f..619bd7c 100644 --- a/src/utils/fql.ts +++ b/src/utils/fql.ts @@ -1,4 +1,13 @@ -import { ExprArg } from 'faunadb'; +import { + Collection, + Documents, + ExprArg, + Get, + Lambda, + Map, + Paginate, + Var, +} from 'faunadb'; import { q } from '../lib/faunadb/faunadb'; const { Exists, If, Delete, Update, CreateFunction, CreateRole, Role } = q; @@ -20,3 +29,17 @@ export const CreateOrUpdateRole = (obj: any) => If( Update(Role(obj.name), { membership: obj.membership, privileges: obj.privileges }), CreateRole(obj), ); + +/** + * + * @param collectionName + * + * @see https://fauna.com/blog/modernizing-from-postgresql-to-serverless-with-fauna-part-1#select + */ +export const SelectAll = (collectionName: string) => Map( + Paginate(Documents(Collection(collectionName))), + Lambda( + ['ref'], + Get(Var('ref')) + ), +); From bbf2b356cc63fc990e5d2dd43d6bb2c8255df557 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 15:15:37 +0100 Subject: [PATCH 028/148] Misc refactoring (no behavior change) --- src/utils/canvasStream.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 8d8f227..2cb48e4 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -43,6 +43,7 @@ export const getUserClient = (user: UserSession | null): Client => { * @param onUpdate */ export const initStream = async (user: UserSession | null, onInit: OnInit, onUpdate: OnUpdate) => { + console.log('Init stream for user', user); const client: Client = getUserClient(user); const canvasRef: Expr | undefined = await findUserCanvasRef(user); @@ -134,11 +135,14 @@ export const findOrCreateUserCanvas = async (user: UserSession): Promise(createUserCanvas); console.log('createUserCanvasResult', createUserCanvasResult); + // Update the current canvas with the new dataset + // setRecoilExternalState(canvasDatasetSelector, canvasDataset); + return createUserCanvasResult?.ref; } catch (e) { console.error(`[findOrCreateUserCanvas] Error while creating canvas:`, e); @@ -188,8 +195,17 @@ export const updateUserCanvas = async (user: UserSession | null, newCanvasDatase console.log('Updating canvas dataset in FaunaDB. Old:', existingCanvasDataset, 'new:', newCanvasDataset); try { - const x: CanvasResult = await client.query(Update(canvasRef, { data: newCanvasDataset })); - console.log('x', x); + const updateCanvasResult: CanvasResult = await client.query(Update(canvasRef, { data: newCanvasDataset })); + console.log('updateCanvasResult', updateCanvasResult); + + const canvasDataset: CanvasDataset = { + nodes: updateCanvasResult?.data?.nodes, + edges: updateCanvasResult?.data?.edges, + }; + console.log('new canvasDataset (from db)', canvasDataset); + + // Update the current canvas with the existing dataset + // setRecoilExternalState(canvasDatasetSelector, canvasDataset) } catch (e) { console.error(`[updateUserCanvas] Error while updating canvas:`, e); From f50e974e03dd292191fc65863dee67f38e7653c5 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 15:21:37 +0100 Subject: [PATCH 029/148] Wait until user is fetched from APi before rendering, to avoid rendering using the wrong user (avoids updating the wrong canvas) --- src/pages/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index e493444..47ec534 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -2,7 +2,9 @@ import { isBrowser } from '@unly/utils'; import { useState } from 'react'; import DisplayOnBrowserMount from '../components/DisplayOnBrowserMount'; import EditorContainer from '../components/editor/EditorContainer'; +import { useUser } from '../components/hooks/useUser'; import Layout from '../components/Layout'; +import { UserSession } from '../types/auth/UserSession'; import { CanvasDataset } from '../types/CanvasDataset'; export type Props = { @@ -30,6 +32,7 @@ export const getStaticProps = (): { props: Props } => { * after it has initialized the global "initialCanvasDataset" browser variable, which is used by the nodesSelector and edgesSelector Recoil state managers. */ const IndexPage = (props: any) => { + const user: UserSession | null | undefined = useUser(); // "user" is "undefined" until a response is received from the API const [canvasDataset, setCanvasDataset] = useState(undefined); /** @@ -53,7 +56,10 @@ const IndexPage = (props: any) => { // deps={[canvasDataset]} > { - + // Wait until the user has been fetched from the API endpoint (returns either "null" or "UserSession") + user !== undefined && ( + + ) } From ff5b238a3bdeea2a9d13b2a8240111aad67c1065 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 15:25:30 +0100 Subject: [PATCH 030/148] Use hard refresh when logging in, simplifies the stream management --- src/components/AuthFormModal.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/AuthFormModal.tsx b/src/components/AuthFormModal.tsx index 9cb6bce..adaa117 100644 --- a/src/components/AuthFormModal.tsx +++ b/src/components/AuthFormModal.tsx @@ -10,8 +10,6 @@ import { ModalOverlay, useDisclosure, } from '@chakra-ui/react'; -import { Magic } from 'magic-sdk'; -import Router from 'next/router'; import React, { useEffect, useState, @@ -46,7 +44,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: { @@ -58,7 +56,12 @@ const AuthFormModal = (props: Props) => { if (res.status === 200) { onClose(); - Router.push('/'); // Forces a re-render + + // Refresh the page, which will automatically close the existing FaunaDB stream (linked to the shared document), and create a new one (linked to the user's document) + // If we simply re-render, we might accidentally update the current shared document using the user's document (or vice-versa), because it updates at every re-render + // XXX There might be a cleaner way to do that that doesn't require a full page refresh + document.location.href = '/'; + // Router.push('/'); // Forces a re-render } else { throw new Error(await res.text()); } From 6e9775d31cacf790783d56df10358c01556253b9 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 16:31:17 +0100 Subject: [PATCH 031/148] Improve logout (best practices) + doc --- src/lib/faunadb/models/userModel.ts | 5 +++++ src/pages/api/logout.ts | 24 ++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/lib/faunadb/models/userModel.ts b/src/lib/faunadb/models/userModel.ts index 608b5db..dba880e 100644 --- a/src/lib/faunadb/models/userModel.ts +++ b/src/lib/faunadb/models/userModel.ts @@ -18,6 +18,11 @@ const { Logout, } = q; +/** + * Helps managing users in the FaunaDB database. + * + * @see https://magic.link/posts/todomvc-magic-nextjs-fauna#step-43-modifying-users-in-faunadb-and-issuing-sessions Inspired from + */ export class UserModel { /** * Creates a new user in the "users" collection. diff --git a/src/pages/api/logout.ts b/src/pages/api/logout.ts index 0e64dbf..4960782 100644 --- a/src/pages/api/logout.ts +++ b/src/pages/api/logout.ts @@ -2,21 +2,41 @@ import { NextApiRequest, NextApiResponse, } from 'next'; -import { getUserSession } from '../../lib/auth/userSession'; import { removeTokenCookie } from '../../lib/auth/authCookies'; import { magicAdmin } from '../../lib/auth/magicAdmin'; +import { getUserSession } from '../../lib/auth/userSession'; +import { UserModel } from '../../lib/faunadb/models/userModel'; import { UserSession } from '../../types/auth/UserSession'; type EndpointRequest = NextApiRequest & { query: {}; }; +/** + * Logs out the current user. + * + * Invalidates FaunaDB token. + * Invalidates Magic token. + * Deletes token cookie. + * + * @param req + * @param res + * + * @see https://magic.link/posts/todomvc-magic-nextjs-fauna#5-logging-users-out Inspired from + */ export const logout = async (req: EndpointRequest, res: NextApiResponse): Promise => { try { const session: UserSession | undefined = await getUserSession(req); if (session?.issuer) { - await magicAdmin.users.logoutByIssuer(session?.issuer as string); + const userModel = new UserModel(); + + // Invalidates both FaunaDB token and Magic token + await Promise.all([ + userModel.invalidateFaunaDBToken(session?.faunaDBToken), + magicAdmin.users.logoutByIssuer(session?.issuer as string), + ]); + removeTokenCookie(res); } } catch (error) { From ee8c53100f55a0f1d311506b783f1de1f2088693 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 16:46:40 +0100 Subject: [PATCH 032/148] Add inspirations --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/README.md b/README.md index 13f4536..2384842 100644 --- a/README.md +++ b/README.md @@ -101,3 +101,51 @@ 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 + +**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 + +**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 + +**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 + +**DevOps:** +- https://github.com/fauna-brecht/fauna-schema-migrate + +**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 From 52936ba34a7ab8ac7adeefa2afe765613ef662b6 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 21:23:14 +0100 Subject: [PATCH 033/148] Optimize by ignore useless changes which tend to refresh the UI several times in a loop (2-5 times), now it doesn't --- src/components/editor/CanvasContainer.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index bcfd1de..57b9b15 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -66,7 +66,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n const { canvasRef, } = props; - const user: UserSession | null = useUser(); + const user: UserSession | null = useUser() as UserSession | null; /** * The canvas ref contains useful properties (xy, scroll, etc.) and functions (zoom, centerCanvas, etc.) @@ -99,7 +99,14 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n // Only save changes once the stream has started, to avoid saving anything until the initial canvas dataset was initialized if (isStreaming) { - updateUserCanvas(user, canvasDataset); + // Ignore dataset changes if the dataset contains only a start node with no edge + const isDefaultDataset = canvasDataset?.nodes?.length === 1 && canvasDataset?.edges?.length === 0 && canvasDataset?.nodes[0]?.data?.type === 'start'; + + if (!isDefaultDataset) { + updateUserCanvas(user, canvasDataset); + } else { + console.info('CanvasDataset has changed. Default dataset detected, database update aborted.'); + } } }, [canvasDataset]); From 744ac48118c983f32c6a557c164bba41449aba32 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 21:24:52 +0100 Subject: [PATCH 034/148] Perform deep diff between before/after dataset to quickly understand what's changed + only consider nodes/edges in existingCanvasDataset when comparing (was causing infinite loop because it was comparing other properties of the Canvas document (owner)) --- package.json | 2 ++ src/utils/canvasStream.ts | 11 ++++++++--- yarn.lock | 7 ++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 4d84ee8..0c1d03e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "animate.css": "4.1.1", "classnames": "2.2.6", "cookie": "0.4.1", + "deep-diff": "1.0.2", "faunadb": "4.1.1", "faunadb-fql-lib": "0.13.0", "framer-motion": "3.2.1", @@ -63,6 +64,7 @@ "@emotion/babel-plugin": "11.1.2", "@types/classnames": "2.2.11", "@types/cookie": "0.4.0", + "@types/deep-diff": "1.0.0", "@types/lodash.capitalize": "4.2.6", "@types/lodash.debounce": "4.0.6", "@types/lodash.filter": "4.6.6", diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 2cb48e4..09a5ddd 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -1,3 +1,4 @@ +import { diff } from 'deep-diff'; import { Client, Collection, @@ -189,10 +190,14 @@ export const updateUserCanvas = async (user: UserSession | null, newCanvasDatase if (canvasRef) { const existingCanvasDatasetResult: CanvasResult = await client.query(Get(canvasRef)); - const existingCanvasDataset: CanvasDataset = existingCanvasDatasetResult.data; + const existingCanvasDataset: CanvasDataset = { + // Consider only nodes/edges and ignore other fields to avoid false-positive difference that musn't be taken into account + nodes: existingCanvasDatasetResult.data?.nodes, + edges: existingCanvasDatasetResult.data?.edges, + }; - if (!isEqual(newCanvasDataset, existingCanvasDataset)) { - console.log('Updating canvas dataset in FaunaDB. Old:', existingCanvasDataset, 'new:', newCanvasDataset); + if (!isEqual(existingCanvasDataset, newCanvasDataset)) { + console.log('Updating canvas dataset in FaunaDB. Old:', existingCanvasDataset, 'new:', newCanvasDataset, 'diff:', diff(existingCanvasDataset, newCanvasDataset)); try { const updateCanvasResult: CanvasResult = await client.query(Update(canvasRef, { data: newCanvasDataset })); diff --git a/yarn.lock b/yarn.lock index f76d5b6..02cb39e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1145,6 +1145,11 @@ resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.0.tgz#14f854c0f93d326e39da6e3b6f34f7d37513d108" integrity sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg== +"@types/deep-diff@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/deep-diff/-/deep-diff-1.0.0.tgz#7eba3202a99b3a207f758f351f7f86387269fc40" + integrity sha512-ENsJcujGbCU/oXhDfQ12mSo/mCBWodT2tpARZKmatoSrf8+cGRCPi0KVj3I0FORhYZfLXkewXu7AoIWqiBLkNw== + "@types/lodash.capitalize@4.2.6": version "4.2.6" resolved "https://registry.yarnpkg.com/@types/lodash.capitalize/-/lodash.capitalize-4.2.6.tgz#261a1c0151872c2eab068b78d9bcd33305a76f92" @@ -2093,7 +2098,7 @@ deep-copy@^1.4.1: resolved "https://registry.yarnpkg.com/deep-copy/-/deep-copy-1.4.2.tgz#0622719257e4bd60240e401ea96718211c5c4697" integrity sha512-VxZwQ/1+WGQPl5nE67uLhh7OqdrmqI1OazrraO9Bbw/M8Bt6Mol/RxzDA6N6ZgRXpsG/W9PgUj8E1LHHBEq2GQ== -deep-diff@^1.0.2: +deep-diff@1.0.2, deep-diff@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-1.0.2.tgz#afd3d1f749115be965e89c63edc7abb1506b9c26" integrity sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg== From d3be6a59c4c188a5afd7cdefeafae74d195fc4ce Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 21:28:33 +0100 Subject: [PATCH 035/148] Remove dead code --- src/components/AuthFormModal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/AuthFormModal.tsx b/src/components/AuthFormModal.tsx index adaa117..12ae28b 100644 --- a/src/components/AuthFormModal.tsx +++ b/src/components/AuthFormModal.tsx @@ -61,7 +61,6 @@ const AuthFormModal = (props: Props) => { // If we simply re-render, we might accidentally update the current shared document using the user's document (or vice-versa), because it updates at every re-render // XXX There might be a cleaner way to do that that doesn't require a full page refresh document.location.href = '/'; - // Router.push('/'); // Forces a re-render } else { throw new Error(await res.text()); } From ff835173ce997bd754511c4f6407c213fc2c7f23 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 21:44:37 +0100 Subject: [PATCH 036/148] Add global styles + Animated3Dots.tsx component --- src/components/Animated3Dots.tsx | 59 ++++++++++++++++++++++++ src/components/GlobalStyles.tsx | 77 ++++++++++++++++++++++++++++++++ src/pages/_app.tsx | 2 + 3 files changed, 138 insertions(+) create mode 100644 src/components/Animated3Dots.tsx create mode 100644 src/components/GlobalStyles.tsx 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/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/pages/_app.tsx b/src/pages/_app.tsx index 49ad0a0..f731446 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -7,6 +7,7 @@ import { import { Router } from 'next/router'; import React from 'react'; import { RecoilRoot } from 'recoil'; +import GlobalStyles from '../components/GlobalStyles'; import { RecoilDevtools } from '../components/RecoilDevtools'; import { RecoilExternalStatePortal } from '../components/RecoilExternalStatePortal'; import '../utils/fontAwesome'; @@ -38,6 +39,7 @@ const App: React.FunctionComponent = (props): JSX.Element => { {/* Utility component allowing to use the Recoil state outside of a React component */} + From c5f78ce50a49095c1bdb9869ccbc22098446782e Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 21:45:19 +0100 Subject: [PATCH 037/148] Improve UX when clicking on the "Send" btn (login/create account) --- src/components/AuthFormModal.tsx | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/components/AuthFormModal.tsx b/src/components/AuthFormModal.tsx index 12ae28b..94b04a4 100644 --- a/src/components/AuthFormModal.tsx +++ b/src/components/AuthFormModal.tsx @@ -15,6 +15,7 @@ import React, { useState, } from 'react'; import { magicClient } from '../lib/auth/magicClient'; +import Animated3Dots from './Animated3Dots'; type Props = { mode: 'login' | 'create-account'; @@ -27,6 +28,7 @@ const AuthFormModal = (props: Props) => { const isLoginForm = mode === 'login'; const { isOpen, onOpen, onClose } = useDisclosure(); const [email, setEmail] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); /** * @@ -35,6 +37,7 @@ const AuthFormModal = (props: Props) => { */ const onSubmit = async (event: MouseEvent): Promise => { event.preventDefault(); + setIsSubmitting(true); try { localStorage?.setItem(LS_EMAIL_KEY, email); @@ -115,19 +118,29 @@ const AuthFormModal = (props: Props) => { - + { + !isSubmitting && ( + + ) + } + From 85c6da6ec200ee976ec238989333b7ff2b22675a Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 21:50:21 +0100 Subject: [PATCH 038/148] Fix TS error --- src/components/FaunaDBCanvasStream.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/FaunaDBCanvasStream.tsx b/src/components/FaunaDBCanvasStream.tsx index ef931a7..3b60361 100644 --- a/src/components/FaunaDBCanvasStream.tsx +++ b/src/components/FaunaDBCanvasStream.tsx @@ -27,7 +27,7 @@ const FaunaDBCanvasStream: React.FunctionComponent = (props) => { // Used to avoid starting several streams from the same browser const [hasStreamStarted, setHasStreamStarted] = useState(false); - const user: UserSession | null = useUser(); + const user: UserSession | null = useUser() as UserSession | null; if (!isBrowser()) { return null; From 34228f494f6398a94834777982fbf14732548a81 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 22:25:03 +0100 Subject: [PATCH 039/148] Better handling of streams, now close stream and open new one when user logs in/out + don't reload full page upon login --- src/components/AuthFormModal.tsx | 8 ++--- src/components/FaunaDBCanvasStream.tsx | 47 ++++++++++++++++++++++++-- src/types/faunadb/CanvasStream.ts | 5 +++ src/types/faunadb/FaunadbToken.ts | 3 +- src/utils/canvasStream.ts | 8 ++++- 5 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/components/AuthFormModal.tsx b/src/components/AuthFormModal.tsx index 94b04a4..660bb72 100644 --- a/src/components/AuthFormModal.tsx +++ b/src/components/AuthFormModal.tsx @@ -58,12 +58,8 @@ const AuthFormModal = (props: Props) => { }); if (res.status === 200) { - onClose(); - - // Refresh the page, which will automatically close the existing FaunaDB stream (linked to the shared document), and create a new one (linked to the user's document) - // If we simply re-render, we might accidentally update the current shared document using the user's document (or vice-versa), because it updates at every re-render - // XXX There might be a cleaner way to do that that doesn't require a full page refresh - document.location.href = '/'; + // 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()); } diff --git a/src/components/FaunaDBCanvasStream.tsx b/src/components/FaunaDBCanvasStream.tsx index 3b60361..359064c 100644 --- a/src/components/FaunaDBCanvasStream.tsx +++ b/src/components/FaunaDBCanvasStream.tsx @@ -1,4 +1,7 @@ +import { css } from '@emotion/react'; import { isBrowser } from '@unly/utils'; +import { values } from 'faunadb'; +import { Subscription } from 'faunadb/src/types/Stream'; import React, { useEffect, useState, @@ -6,11 +9,14 @@ import React, { import { UserSession } from '../types/auth/UserSession'; import { OnInit, + OnStart, OnUpdate, } from '../types/faunadb/CanvasStream'; import { initStream } from '../utils/canvasStream'; import { useUser } from './hooks/useUser'; +type Ref = values.Ref; + type Props = { onInit: OnInit; onUpdate: OnUpdate; @@ -27,21 +33,56 @@ const FaunaDBCanvasStream: React.FunctionComponent = (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 user: UserSession | null = useUser() as UserSession | null; if (!isBrowser()) { return null; } + const onStart: OnStart = (stream: Subscription, canvasRef: Ref) => { + setStream(stream); + setCanvasRef(canvasRef); + }; + + /** + * Handles stream subscription + * + * Handles stream initialization and changes when the user logs in and logs out. + * Updates when the user changes. + */ useEffect(() => { + 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, onInit, onUpdate); + initStream(user, onStart, onInit, onUpdate); + } else { + // 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, onStart, onInit, onUpdate); } - }, []); + }, [user]); - return null; + // 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/types/faunadb/CanvasStream.ts b/src/types/faunadb/CanvasStream.ts index 25b4adc..cbbc191 100644 --- a/src/types/faunadb/CanvasStream.ts +++ b/src/types/faunadb/CanvasStream.ts @@ -1,4 +1,9 @@ +import { values } from 'faunadb'; +import { Subscription } from 'faunadb/src/types/Stream'; import { CanvasDataset } from '../CanvasDataset'; +type Ref = values.Ref; + +export type OnStart = (stream: Subscription, canvasRef: Ref) => void; export type OnInit = (canvasDataset: CanvasDataset) => void; export type OnUpdate = (canvasDatasetRemotelyUpdated: CanvasDataset) => void; diff --git a/src/types/faunadb/FaunadbToken.ts b/src/types/faunadb/FaunadbToken.ts index c84df1d..8de3334 100644 --- a/src/types/faunadb/FaunadbToken.ts +++ b/src/types/faunadb/FaunadbToken.ts @@ -1,6 +1,7 @@ import { values } from 'faunadb'; import { FaunadbBaseFields } from './FaunadbBaseFields'; -import Ref = values.Ref; + +type Ref = values.Ref; /** * A Token created by FaunaDB. diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 09a5ddd..3c20cd8 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -10,6 +10,7 @@ import { Paginate, Ref, Update, + values, } from 'faunadb'; import { Subscription } from 'faunadb/src/types/Stream'; import isEqual from 'lodash.isequal'; @@ -23,10 +24,13 @@ import { CanvasByOwnerIndex } from '../types/faunadb/CanvasByOwnerIndex'; import { CanvasResult } from '../types/faunadb/CanvasResult'; import { OnInit, + OnStart, OnUpdate, } from '../types/faunadb/CanvasStream'; import { FaunadbStreamVersionEvent } from '../types/faunadb/FaunadbStreamVersionEvent'; +type TypeOfRef = values.Ref; + const PUBLIC_SHARED_FAUNABD_TOKEN = process.env.NEXT_PUBLIC_SHARED_FAUNABD_TOKEN as string; const SHARED_CANVAS_DOCUMENT_ID = '1'; @@ -40,10 +44,11 @@ export const getUserClient = (user: UserSession | null): Client => { * Starts the real-time stream between the browser and the FaunaDB database, on a specific record/document. * * @param user + * @param onStart * @param onInit * @param onUpdate */ -export const initStream = async (user: UserSession | null, onInit: OnInit, onUpdate: OnUpdate) => { +export const initStream = async (user: UserSession | null, onStart: OnStart, onInit: OnInit, onUpdate: OnUpdate) => { console.log('Init stream for user', user); const client: Client = getUserClient(user); const canvasRef: Expr | undefined = await findUserCanvasRef(user); @@ -60,6 +65,7 @@ export const initStream = async (user: UserSession | null, onInit: OnInit, onUpd document(canvasRef) .on('start', (at: number) => { console.log('Stream started at:', at); + onStart(stream, canvasRef as TypeOfRef); }) .on('snapshot', (snapshot: CanvasResult) => { console.log('snapshot', snapshot); From e70cef8fd08cc15455ff0ad75b109c14c579f3da Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 11 Mar 2021 22:50:36 +0100 Subject: [PATCH 040/148] Store "ref" and "id" into the UserSession + use it to fetch the canvas from the current user id instead of hardcoded user id --- src/pages/api/login.ts | 3 +++ src/types/UserMetadataWithAuth.ts | 4 ++++ src/utils/canvasStream.ts | 4 ++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts index 028fe7c..914fe50 100644 --- a/src/pages/api/login.ts +++ b/src/pages/api/login.ts @@ -48,6 +48,7 @@ export const login = async (req: EndpointRequest, res: NextApiResponse): Promise // Auto-detects new user sign-up when `getUserByEmail` resolves to `undefined` const user: User = (await userModel.getUserByEmail(userMetadata?.email) ?? await userModel.createUser(userMetadata?.email)) as User; + console.log('Found user', user); // Generates a FaunaDB token specific associated to this user const faunaDBToken: string | undefined = await userModel.obtainFaunaDBToken(user); @@ -63,6 +64,8 @@ export const login = async (req: EndpointRequest, res: NextApiResponse): Promise const userMetadataWithAuth: UserMetadataWithAuth = { ...userMetadata, faunaDBToken, + ref: user.ref, + id: user.ref.id, }; // Those metadata are then used to generate a login session (Magic metadata + custom login metadata) diff --git a/src/types/UserMetadataWithAuth.ts b/src/types/UserMetadataWithAuth.ts index 58c241e..050cfdb 100644 --- a/src/types/UserMetadataWithAuth.ts +++ b/src/types/UserMetadataWithAuth.ts @@ -1,5 +1,9 @@ import { MagicUserMetadata } from '@magic-sdk/admin'; +import { values } from 'faunadb'; +type Ref = values.Ref; export type UserMetadataWithAuth = MagicUserMetadata & { faunaDBToken: string; + ref: Ref; + id: string; } diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 3c20cd8..e405418 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -132,7 +132,7 @@ export const findOrCreateUserCanvas = async (user: UserSession): Promise Date: Thu, 11 Mar 2021 23:00:25 +0100 Subject: [PATCH 041/148] Update footer, add Magic link --- src/components/Footer.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index d012f80..c61f893 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -66,7 +66,7 @@ const Footer: React.FunctionComponent = (props) => { {' '}FaunaDB{' '} - {' '}and{' '} + {' '}, {' '} = (props) => { {' '}Reaflow{' '} + {' '} and some {' '} + + {' '}Magic{' '} + + From 3312cc41a149a21822c83b0d5bb025c4c06c369e Mon Sep 17 00:00:00 2001 From: Dan Hen <37185115+Dan-Hen@users.noreply.github.com> Date: Fri, 12 Mar 2021 00:23:07 +0100 Subject: [PATCH 042/148] modify UI styling (#10) Co-authored-by: Dhenain Ambroise --- src/components/FaunaDBCanvasStream.tsx | 2 +- src/components/Nav.tsx | 2 +- src/components/blocks/BlockPickerMenu.tsx | 10 +- src/components/blocks/IfBlock.tsx | 6 +- src/components/blocks/InformationBlock.tsx | 6 +- src/components/blocks/QuestionBlock.tsx | 6 +- src/components/edges/AbsoluteLabelEditor.tsx | 121 ++++++++++++++++++ src/components/edges/BaseEdge.tsx | 77 ++++++++++- src/components/edges/Label.tsx | 44 +++++++ src/components/editor/CanvasContainer.tsx | 50 ++++++-- src/components/editor/PlaygroundContainer.tsx | 2 + src/components/nodes/BaseNode.tsx | 34 ++++- src/components/nodes/EndNode.tsx | 84 ++++++++++++ src/components/nodes/QuestionNode.tsx | 6 + src/components/nodes/StartNode.tsx | 7 +- src/components/plugins/QuestionChoice.tsx | 25 +++- src/components/plugins/VariableNameInput.tsx | 60 ++++++++- src/{components => }/hooks/useCanvasUtils.tsx | 2 +- src/hooks/useFocus.tsx | 24 ++++ src/{components => }/hooks/useUser.ts | 6 +- src/pages/_app.tsx | 57 ++++++++- src/pages/index.tsx | 2 +- src/settings.ts | 2 +- src/states/absoluteLabelEditorStateState.ts | 12 ++ src/types/AbsoluteLabelEditorContent.ts | 7 + src/types/BaseEdgeProps.ts | 3 + src/types/NodeType.ts | 1 + src/utils/fontAwesome.ts | 6 + src/utils/nodes.ts | 3 + 29 files changed, 607 insertions(+), 60 deletions(-) create mode 100644 src/components/edges/AbsoluteLabelEditor.tsx create mode 100644 src/components/edges/Label.tsx create mode 100644 src/components/nodes/EndNode.tsx rename src/{components => }/hooks/useCanvasUtils.tsx (88%) create mode 100644 src/hooks/useFocus.tsx rename src/{components => }/hooks/useUser.ts (92%) create mode 100644 src/states/absoluteLabelEditorStateState.ts create mode 100644 src/types/AbsoluteLabelEditorContent.ts diff --git a/src/components/FaunaDBCanvasStream.tsx b/src/components/FaunaDBCanvasStream.tsx index 359064c..67b09cc 100644 --- a/src/components/FaunaDBCanvasStream.tsx +++ b/src/components/FaunaDBCanvasStream.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useState, } from 'react'; +import { useUser } from '../hooks/useUser'; import { UserSession } from '../types/auth/UserSession'; import { OnInit, @@ -13,7 +14,6 @@ import { OnUpdate, } from '../types/faunadb/CanvasStream'; import { initStream } from '../utils/canvasStream'; -import { useUser } from './hooks/useUser'; type Ref = values.Ref; diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 52a3aca..3a8c66c 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -10,7 +10,7 @@ import { css } from '@emotion/react'; import React, { Fragment } from 'react'; import settings from '../settings'; import AuthFormModal from './AuthFormModal'; -import { useUser } from './hooks/useUser'; +import { useUser } from '../hooks/useUser'; type Props = {} 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..50e3144 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,7 +21,7 @@ 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'; @@ -33,6 +34,7 @@ import { getDefaultNodePropsWithFallback, upsertNodeThroughPorts, } from '../../utils/nodes'; +import Label from './Label'; type Props = {} & BaseEdgeProps; @@ -54,6 +56,7 @@ const BaseEdge: React.FunctionComponent = (props) => { target: targetNodeId, targetPort: targetPortId, } = props; + // console.log('props', props) const [blockPickerMenu, setBlockPickerMenu] = useRecoilState(blockPickerMenuSelector); const [canvasDataset, setCanvasDataset] = useRecoilState(canvasDatasetSelector); @@ -64,6 +67,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,9 +94,7 @@ const BaseEdge: React.FunctionComponent = (props) => { * @param event */ const onAddIconClick = (event: React.MouseEvent): 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); @@ -142,9 +144,40 @@ const BaseEdge: React.FunctionComponent = (props) => { setSelectedEdges([edge.id]); }; + /** + * Path the properties of the current node. + * + * Only updates the provided properties, doesn't update other properties. + * Also merges the 'data' object, by keeping existing data and only overwriting those that are specified. + * + * XXX Make sure to call this function once per function call, otherwise only the last patch call would be persisted correctly + * (multiple calls within the same function would be overridden by the last patch, + * because the "node" used as reference wouldn't be updated right away and would still use the same (outdated) reference) + * TLDR; Don't use "patchCurrentNode" multiple times in the same function, it won't work as expected + * + * @param patch + */ + const patchCurrentEdge: PatchCurrentEdge = (patch: Partial): void => { + const edgeToUpdateIndex = edges.findIndex((edge: BaseEdgeData) => edge.id === id); + const existingEdge: BaseEdgeData = edges[edgeToUpdateIndex]; + const edgeToUpdate = { + ...existingEdge, + ...patch, + id: existingEdge.id, // Force keep same id to avoid edge cases + }; + console.log('patchCurrentEdge before', existingEdge, 'after:', edgeToUpdate, 'using patch:', patch); + + const newEdges = cloneDeep(edges); + // @ts-ignore + newEdges[edgeToUpdateIndex] = edgeToUpdate; + + setEdges(newEdges); + }; + return ( } className={classnames(`edge-svg-graph`, { 'is-selected': isSelected })} onClick={onEdgeClick} > @@ -158,6 +191,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 +253,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 57b9b15..4b9807f 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -16,6 +16,7 @@ import { useUndo, } from 'reaflow'; import { useRecoilState } from 'recoil'; +import { useUser } from '../../hooks/useUser'; import settings from '../../settings'; import { blockPickerMenuSelector } from '../../states/blockPickerMenuState'; import { canvasDatasetSelector } from '../../states/canvasDatasetSelector'; @@ -32,14 +33,18 @@ import { updateUserCanvas, } from '../../utils/canvasStream'; import { isOlderThan } from '../../utils/date'; +import { createEdge } from '../../utils/edges'; import { createNodeFromDefaultProps, getDefaultNodePropsWithFallback, } from '../../utils/nodes'; +import { + getDefaultFromPort, + getDefaultToPort, +} from '../../utils/ports'; import canvasUtilsContext from '../context/canvasUtilsContext'; import BaseEdge from '../edges/BaseEdge'; import FaunaDBCanvasStream from '../FaunaDBCanvasStream'; -import { useUser } from '../hooks/useUser'; import NodeRouter from '../nodes/NodeRouter'; type Props = { @@ -99,8 +104,11 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n // Only save changes once the stream has started, to avoid saving anything until the initial canvas dataset was initialized if (isStreaming) { - // Ignore dataset changes if the dataset contains only a start node with no edge - const isDefaultDataset = canvasDataset?.nodes?.length === 1 && canvasDataset?.edges?.length === 0 && canvasDataset?.nodes[0]?.data?.type === 'start'; + // Ignore dataset changes if the dataset contains only: + // - a start node with no edge + // - or a start node and an end node and one edge + const isDefaultDataset = canvasDataset?.nodes?.length === 1 && canvasDataset?.edges?.length === 0 && canvasDataset?.nodes[0]?.data?.type === 'start' + || canvasDataset?.nodes?.length === 2 && canvasDataset?.edges?.length === 1 && canvasDataset?.nodes[0]?.data?.type === 'start' && canvasDataset?.nodes[1]?.data?.type === 'end'; if (!isDefaultDataset) { updateUserCanvas(user, canvasDataset); @@ -140,20 +148,31 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n }); /** - * Ensures the start node is always present. + * Ensures the "start" node and "end" node are always present. * - * Will automatically create the start node even if all the nodes are deleted. + * 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 startNode: BaseNodeData | undefined = nodes?.find((node: BaseNodeData) => node?.data?.type === 'start'); + 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) && isStreaming) { + console.info(`No "start" or "end" node found. Creating them automatically.`, nodes); + const startNode: BaseNodeData = createNodeFromDefaultProps(getDefaultNodePropsWithFallback('start')); + const endNode: BaseNodeData = createNodeFromDefaultProps(getDefaultNodePropsWithFallback('end')); + const newNodes = [ + startNode, + endNode, + ]; + const newEdges = [ + createEdge(startNode, endNode, getDefaultFromPort(startNode), getDefaultToPort(endNode)), + ]; - if (!startNode && isStreaming) { - console.info(`No "start" node found. Creating one automatically.`, nodes); - setNodes([ - ...nodes, - createNodeFromDefaultProps(getDefaultNodePropsWithFallback('start')), - ]); + setCanvasDataset({ + nodes: newNodes, + edges: newEdges, + }); // Clearing the undo/redo history to avoid allowing the editor to "undo" the creation of the "start" node // If the "start" node creation step is "undoed" then it'd be re-created automatically, which would erase the whole history @@ -301,12 +320,14 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n style={{ position: 'absolute', top: 10, left: 20, zIndex: 999 }} > diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index 57d53cc..d061c84 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -19,7 +19,7 @@ import { useRecoilState } from 'recoil'; import { useDebouncedCallback } from 'use-debounce'; import { usePreviousValue } from '../../hooks/usePreviousValue'; import useRenderingTrace from '../../hooks/useTraceUpdate'; -import { useUser } from '../../hooks/useUser'; +import { useUserSession } from '../../hooks/useUserSession'; import settings from '../../settings'; import { blockPickerMenuSelector } from '../../states/blockPickerMenuState'; import { canvasDatasetSelector } from '../../states/canvasDatasetSelector'; @@ -73,7 +73,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n const { canvasRef, } = props; - const user: UserSession | null = useUser() as UserSession | null; + const userSession = useUserSession(); /** * The canvas ref contains useful properties (xy, scroll, etc.) and functions (zoom, centerCanvas, etc.) @@ -126,7 +126,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n * Thanks to debouncing, there is only one actual DB update. */ const debouncedUpdateUserCanvas = useDebouncedCallback( - (canvasRef: TypeOfRef | undefined, user: UserSession | null, newCanvasDataset: CanvasDataset, previousCanvasDataset: CanvasDataset | undefined) => { + (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 @@ -141,7 +141,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n useEffect(() => { // Only save changes once the stream has started, to avoid saving anything until the initial canvas dataset was initialized if (isStreaming) { - debouncedUpdateUserCanvas(canvasDocRef, user, canvasDataset, previousCanvasDataset); + debouncedUpdateUserCanvas(canvasDocRef, userSession, canvasDataset, previousCanvasDataset); } }, [canvasDataset]); diff --git a/src/hooks/useUser.ts b/src/hooks/useUserSession.ts similarity index 62% rename from src/hooks/useUser.ts rename to src/hooks/useUserSession.ts index 350a951..f8dbe2f 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUserSession.ts @@ -1,6 +1,7 @@ import Router from 'next/router'; import { useEffect } from 'react'; import useSWR from 'swr'; +import { v1 as uuid } from 'uuid'; import { ApiGetUserResult } from '../pages/api/user'; import { UserSession } from '../types/auth/UserSession'; @@ -9,6 +10,13 @@ type Props = { redirectIfFound?: boolean; } +/** + * Generate an ephemeral session id. + * + * This session ephemeral id will be regenerate for each page refresh. + */ +const sessionEphemeralId: string = uuid(); + /** * The fetcher is an async function that accepts the key of SWR, and returns the data. * @@ -28,14 +36,16 @@ const fetcher = (url: string): Promise => /** * Fetches the current user from our internal /api/user and returns it. * - * The user might not be authenticated, which in this case will return "null". - * The query might not be done, which in this case will return "undefined". + * Until the query is completed (async), it will return a partial UserSession. + * Will return a UserSession instance if the user is authenticated. + * + * You can use "isSessionReady" to know whether the session is ready or not. * * @param props * * @see https://swr.vercel.app/ */ -export const useUser = (props?: Props): UserSession | null | undefined => { +export const useUserSession = (props?: Props): Partial => { const { redirectTo, redirectIfFound } = props || {}; const { data, @@ -69,6 +79,23 @@ export const useUser = (props?: Props): UserSession | null | undefined => { console.error(error); } - // "user" might be "undefined" or an instance of "UserSession" - return error ? null : user; + if (error) { + return { + sessionEphemeralId, + isSessionReady: false, + error: error, + }; + } else if (user) { + return { + ...data?.user || {}, + sessionEphemeralId, + isSessionReady: !isLoading, + }; + } else { + // The user session contains only partial information when the user isn't authenticated (or when the query is still loading) + return { + sessionEphemeralId, + isSessionReady: !isLoading, + }; + } }; diff --git a/src/lib/auth/userSession.ts b/src/lib/auth/userSession.ts index b044917..4ba08b1 100644 --- a/src/lib/auth/userSession.ts +++ b/src/lib/auth/userSession.ts @@ -29,10 +29,12 @@ export const setUserSession = async (res: NextApiResponse, userMetadata: UserMet const createdAt = Date.now(); // Create a session object with a max age that we can validate later - const userSession: UserSession = { + // "sessionEphemeralId" and "isSessionReady" are being omitted because they're set on the client, not on the server + const userSession: Omit = { ...userMetadata, createdAt, maxAge: MAX_AGE, + isAuthenticated: true, }; const token: string = await encryptData(userSession); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index b14e580..fb61cef 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,10 +1,7 @@ -import { isBrowser } from '@unly/utils'; -import { useState } from 'react'; import DisplayOnBrowserMount from '../components/DisplayOnBrowserMount'; import EditorContainer from '../components/editor/EditorContainer'; import Layout from '../components/Layout'; -import { useUser } from '../hooks/useUser'; -import { UserSession } from '../types/auth/UserSession'; +import { useUserSession } from '../hooks/useUserSession'; import { CanvasDataset } from '../types/CanvasDataset'; export type Props = { @@ -15,6 +12,8 @@ export type Props = { * You can use your custom business logic here to fetch the canvasDataset from your data storage. * We simplified this demo by storing the canvasDataset in the browser LocalStorage instead. * + * TODO doc + * * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation */ export const getStaticProps = (): { props: Props } => { @@ -32,22 +31,7 @@ export const getStaticProps = (): { props: Props } => { * after it has initialized the global "initialCanvasDataset" browser variable, which is used by the nodesSelector and edgesSelector Recoil state managers. */ const IndexPage = (props: any) => { - const user: UserSession | null | undefined = useUser(); // "user" is "undefined" until a response is received from the API - const [canvasDataset, setCanvasDataset] = useState(undefined); - - /** - * Gets the canvas dataset stored in the browser localstorage and makes it available in the global "window" object. - * The window.initialCanvasDataset will be used by the nodes/edges atom during their initialisation. - * - * XXX Doing it this way (instead of using a setState) ensures the Canvas is initially loaded with the proper dataset. - * And it won't have multiple re-renders due to mutating state, which in turn avoids lagginess during init. - * Also, it's a viable approach whether using the data from browser localstorage, or a real DB. - */ - if (isBrowser()) { - // if (canvasDataset) { - // window.initialCanvasDataset = canvasDataset; - // } - } + const user = useUserSession(); // "user" is "undefined" until a response is received from the API return ( @@ -56,8 +40,8 @@ const IndexPage = (props: any) => { // deps={[canvasDataset]} > { - // Wait until the user has been fetched from the API endpoint (returns either "null" or "UserSession") - user !== undefined && ( + // Wait until the user has been fetched from the API endpoint + user?.isSessionReady === true && ( ) } diff --git a/src/types/auth/UserSession.ts b/src/types/auth/UserSession.ts index da1a9c6..f8476e3 100644 --- a/src/types/auth/UserSession.ts +++ b/src/types/auth/UserSession.ts @@ -1,6 +1,48 @@ import { UserMetadataWithAuth } from '../UserMetadataWithAuth'; +/** + * User session stored in the "token" cookie. + */ export type UserSession = UserMetadataWithAuth & { + + /** + * Whether the user is authenticated. + */ + isAuthenticated: boolean; + + /** + * Timestamp of creation. + */ createdAt: number; + + /** + * Auto-expires when maxAge is reached (similar to TTL). + * + * If the cookie expires due to maxAge being reached, the authentication form won't ask for email link confirmation, + * but will automatically log in the user instead. (it somehow remembers the user because of cookies and uses a light authentication) + */ maxAge: number; + + /** + * Contains a UUID string that is generated on the client side. + * + * It's meant to track which session is being used by the user. + * The id will change upon page refresh (it is, ephemeral). + * + * It is used by the FaunaDB stream to resolve whether updates published by the DB are coming from the same session, + * and discard them if they're coming from the same session. + */ + sessionEphemeralId: string; + + /** + * Whether the session has been fetched from the API or is loading. + * + * If the query errors, it will return false. (we consider the session isn't ready in such case) + */ + isSessionReady: boolean; + + /** + * Error that might happen when fetching the session. + */ + error?: Error; }; diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index a7e126e..65d07aa 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -32,7 +32,7 @@ import { TypeOfRef } from '../types/faunadb/TypeOfRef'; const PUBLIC_SHARED_FAUNABD_TOKEN = process.env.NEXT_PUBLIC_SHARED_FAUNABD_TOKEN as string; const SHARED_CANVAS_DOCUMENT_ID = '1'; -export const getUserClient = (user: UserSession | null): Client => { +export const getUserClient = (user: Partial): Client => { const secret = user?.faunaDBToken || PUBLIC_SHARED_FAUNABD_TOKEN; return getClient(secret); @@ -46,7 +46,7 @@ export const getUserClient = (user: UserSession | null): Client => { * @param onInit * @param onUpdate */ -export const initStream = async (user: UserSession | null, onStart: OnStart, onInit: OnInit, onUpdate: OnUpdate) => { +export const initStream = async (user: Partial, onStart: OnStart, onInit: OnInit, onUpdate: OnUpdate) => { console.log('Init stream for user', user); const client: Client = getUserClient(user); const canvasRef: Expr | undefined = await findUserCanvasRef(user); @@ -126,7 +126,7 @@ export const initStream = async (user: UserSession | null, onStart: OnStart, onI * Always use "1" as document ref id. * There is only one document in the DB, and the same document is shared with all users. */ -export const findUserCanvasRef = async (user: UserSession | null): Promise => { +export const findUserCanvasRef = async (user: Partial): Promise => { if (user) { return await findOrCreateUserCanvas(user); } else { @@ -139,7 +139,7 @@ export const findUserCanvasRef = async (user: UserSession | null): Promise => { +export const findOrCreateUserCanvas = async (user: Partial): Promise => { const client: Client = getUserClient(user); const findUserCanvas = Paginate( Match( @@ -224,7 +224,7 @@ export const hasDatasetChanged = (newCanvasDataset: CanvasDataset): boolean => { * @param newCanvasDataset * @param previousCanvasDataset */ -export const updateUserCanvas = async (canvasRef: TypeOfRef | undefined, user: UserSession | null, newCanvasDataset: CanvasDataset, previousCanvasDataset: CanvasDataset | undefined): Promise => { +export const updateUserCanvas = async (canvasRef: TypeOfRef | undefined, user: Partial, newCanvasDataset: CanvasDataset, previousCanvasDataset: CanvasDataset | undefined): Promise => { const client: Client = getUserClient(user); try { From b6b8d76816cb2dafdc5788e9311a7dc8f0f0af06 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Tue, 16 Mar 2021 16:36:43 +0100 Subject: [PATCH 092/148] Misc TS doc --- src/types/faunadb/User.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/types/faunadb/User.ts b/src/types/faunadb/User.ts index fd7bc85..d8cb79b 100644 --- a/src/types/faunadb/User.ts +++ b/src/types/faunadb/User.ts @@ -1,5 +1,8 @@ import { FaunadbRecordBaseFields } from './FaunadbRecordBaseFields'; +/** + * User type in FaunaDB. + */ export type User = FaunadbRecordBaseFields<{ email: string; }> From ce4bea82b13e009cb4fb814d38421b53d0189f67 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Tue, 16 Mar 2021 16:46:25 +0100 Subject: [PATCH 093/148] Store lastUpdatedBySessionEphemeralId and lastUpdatedByUserName when creating/saving the canvas dataset, and ignoring updates coming from the same device (will help reduce synchronization issues) --- src/types/faunadb/Canvas.ts | 18 ++++++++++++++---- src/utils/canvasStream.ts | 25 +++++++++++++++++-------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/types/faunadb/Canvas.ts b/src/types/faunadb/Canvas.ts index faa08ab..7ed9095 100644 --- a/src/types/faunadb/Canvas.ts +++ b/src/types/faunadb/Canvas.ts @@ -3,16 +3,23 @@ import BaseEdgeData from '../BaseEdgeData'; import BaseNodeData from '../BaseNodeData'; import { FaunadbRecordBaseFields } from './FaunadbRecordBaseFields'; -export type Canvas = FaunadbRecordBaseFields<{ +export type CanvasData = { /** * Owner (user) of the canvas dataset. */ owner: Expr; /** - * Last editor (user) who's updated the canvas dataset. + * Ephemeral session id of the editor who's updated the canvas dataset at last. + * + * The ephemeral session id is unique per page, so it won't be the same if the user has two tabs open. */ - lastEditorId: string; + lastUpdatedBySessionEphemeralId: string; + + /** + * Name of the editor who's updated the canvas dataset at last. + */ + lastUpdatedByUserName: string; /** * Nodes of the canvas dataset. @@ -23,4 +30,7 @@ export type Canvas = FaunadbRecordBaseFields<{ * Edges of the canvas dataset. */ edges: BaseEdgeData[]; -}> +}; + +export type Canvas = FaunadbRecordBaseFields; +export type UpdateCanvas = FaunadbRecordBaseFields>; diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 65d07aa..2a2567d 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -18,7 +18,10 @@ import { getClient } from '../lib/faunadb/faunadb'; import { canvasDatasetSelector } from '../states/canvasDatasetSelector'; import { UserSession } from '../types/auth/UserSession'; import { CanvasDataset } from '../types/CanvasDataset'; -import { Canvas } from '../types/faunadb/Canvas'; +import { + Canvas, + UpdateCanvas, +} from '../types/faunadb/Canvas'; import { CanvasByOwnerIndex } from '../types/faunadb/CanvasByOwnerIndex'; import { CanvasDatasetResult } from '../types/faunadb/CanvasDatasetResult'; import { @@ -75,8 +78,8 @@ export const initStream = async (user: Partial, onStart: OnStart, o if (version.action === 'update') { const canvasDatasetFromRemote: CanvasDatasetResult = version.document; - if (canvasDatasetFromRemote?.data?.lastEditorId !== user?.id) { - console.log('[Streaming] Update event received from different editor is being applied.'); + if (canvasDatasetFromRemote?.data?.lastUpdatedBySessionEphemeralId !== user?.sessionEphemeralId) { + console.log(`[Streaming] Update event received from different editor "${canvasDatasetFromRemote?.data?.lastUpdatedByUserName}" is being applied.`); onUpdate(canvasDatasetFromRemote?.data); } else { console.log('[Streaming] Update event received from same editor has been ignored.'); @@ -160,9 +163,13 @@ export const findOrCreateUserCanvas = async (user: Partial): Promis }; const canvas: Canvas = { data: { - owner: Ref(Collection('Users'), user.id), - lastEditorId: user.id, ...canvasDataset, + owner: Ref(Collection('Users'), user.id), + + // Indicating who's the editor who's made the change, so we can safely ignore the "version:update" event we'll soon receive + // when the DB will notify all subscribed editors about the update + lastUpdatedBySessionEphemeralId: user.sessionEphemeralId as string, + lastUpdatedByUserName: user?.email || `Anonymous#${user?.id?.substring(0, 8)}`, }, }; @@ -258,15 +265,17 @@ export const updateUserCanvas = async (canvasRef: TypeOfRef | undefined, user: P console.debug('[updateUserCanvas] Updating canvas dataset in FaunaDB. Old:', previousCanvasDataset, 'new:', newCanvasDataset, 'diff:', remoteDiff); try { - const updateCanvasDatasetResult: CanvasDatasetResult = await client.query(Update(canvasRef, { + const newCanvas: UpdateCanvas = { data: { ...newCanvasDataset, // Indicating who's the editor who's made the change, so we can safely ignore the "version:update" event we'll soon receive // when the DB will notify all subscribed editors about the update - lastEditorId: user?.id, + lastUpdatedBySessionEphemeralId: user.sessionEphemeralId as string, + lastUpdatedByUserName: user?.email || `Anonymous#${user?.id?.substring(0, 8)}`, }, - })); + } + const updateCanvasDatasetResult: CanvasDatasetResult = await client.query(Update(canvasRef, newCanvas)); console.log('[updateUserCanvas] updateCanvasResult', updateCanvasDatasetResult); } catch (e) { console.error(`[updateUserCanvas] Error while updating canvas:`, e); From a47f28d341e8748818c019d5aed62abea2d84b80 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 10:35:12 +0100 Subject: [PATCH 094/148] Remove dead code (misc imports) --- src/lib/auth/userSession.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/auth/userSession.ts b/src/lib/auth/userSession.ts index 4ba08b1..d139687 100644 --- a/src/lib/auth/userSession.ts +++ b/src/lib/auth/userSession.ts @@ -1,5 +1,3 @@ -import Iron from '@hapi/iron'; -import { MagicUserMetadata } from '@magic-sdk/admin'; import { NextApiRequest, NextApiResponse, @@ -11,7 +9,10 @@ import { MAX_AGE, setTokenCookie, } from './authCookies'; -import { decryptToken, encryptData } from './crypto'; +import { + decryptToken, + encryptData, +} from './crypto'; type EndpointRequest = NextApiRequest & { query: {}; From 925faf5e469008dc01f2246a42e0a9ffc422321b Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 10:40:15 +0100 Subject: [PATCH 095/148] Fix loading canvas for anonymous users --- src/utils/canvasStream.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 2a2567d..8daad66 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -130,7 +130,7 @@ export const initStream = async (user: Partial, onStart: OnStart, o * There is only one document in the DB, and the same document is shared with all users. */ export const findUserCanvasRef = async (user: Partial): Promise => { - if (user) { + if (user?.isAuthenticated) { return await findOrCreateUserCanvas(user); } else { return Ref(Collection('Canvas'), SHARED_CANVAS_DOCUMENT_ID); @@ -274,7 +274,7 @@ export const updateUserCanvas = async (canvasRef: TypeOfRef | undefined, user: P lastUpdatedBySessionEphemeralId: user.sessionEphemeralId as string, lastUpdatedByUserName: user?.email || `Anonymous#${user?.id?.substring(0, 8)}`, }, - } + }; const updateCanvasDatasetResult: CanvasDatasetResult = await client.query(Update(canvasRef, newCanvas)); console.log('[updateUserCanvas] updateCanvasResult', updateCanvasDatasetResult); } catch (e) { From 4cd35e7bc4b470e97f3bada2d1854354289ee761 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 10:47:31 +0100 Subject: [PATCH 096/148] Adapt from d511084b4470f30733e2f2397f7aee08aaa64f09 --- src/components/nodes/InformationNode.tsx | 42 +++++++++++++----------- src/components/nodes/QuestionNode.tsx | 20 ----------- 2 files changed, 22 insertions(+), 40 deletions(-) diff --git a/src/components/nodes/InformationNode.tsx b/src/components/nodes/InformationNode.tsx index 1718bae..b5c0976 100644 --- a/src/components/nodes/InformationNode.tsx +++ b/src/components/nodes/InformationNode.tsx @@ -1,15 +1,13 @@ -import React, { - Fragment, - useEffect, - useState, -} from 'react'; +import React, { Fragment } from 'react'; import { DebounceInput } from 'react-debounce-input'; import { TextareaHeightChangeMeta } from 'react-textarea-autosize/dist/declarations/src'; import settings from '../../settings'; import BaseNodeComponent from '../../types/BaseNodeComponent'; import { BaseNodeDefaultProps } from '../../types/BaseNodeDefaultProps'; import BaseNodeProps from '../../types/BaseNodeProps'; +import { InformationNodeAdditionalData } from '../../types/nodes/InformationNodeAdditionalData'; import { InformationNodeData } from '../../types/nodes/InformationNodeData'; +import { QuestionNodeAdditionalData } from '../../types/nodes/QuestionNodeAdditionalData'; import { SpecializedNodeProps } from '../../types/nodes/SpecializedNodeProps'; import NodeType from '../../types/NodeType'; import { isYoungerThan } from '../../utils/date'; @@ -45,7 +43,6 @@ const InformationNode: BaseNodeComponent = (props) => { lastCreated, patchCurrentNode, } = nodeProps; - const [informationTextareaAdditionalHeight, setInformationTextareaAdditionalHeight] = useState(0); const lastCreatedNode = lastCreated?.node; const lastCreatedAt = lastCreated?.at; @@ -53,20 +50,14 @@ const InformationNode: BaseNodeComponent = (props) => { const shouldAutofocus = false && lastCreatedNode?.id === node.id && isYoungerThan(lastCreatedAt, 1000); /** - * Calculates the node's height dynamically. + * Calculates the node's height based on the dynamic source that affect the dynamic height of the component. * - * The node's height is dynamic and depends on various parameters (length of text, etc.). + * @param dynHeights */ - useEffect(() => { - const newHeight = baseHeight + informationTextareaAdditionalHeight; - - // Only update the height if it's different - if (node?.height !== newHeight) { - patchCurrentNode({ - height: newHeight, - }); - } - }, [informationTextareaAdditionalHeight]); + const calculateNodeHeight = (dynHeights?: InformationNodeAdditionalData['dynHeights']): number => { + return (dynHeights?.baseHeight || baseHeight) + + (dynHeights?.informationTextareaHeight || 0); + }; /** * When textarea input height changes, we need to increase the height of the whole node accordingly. @@ -78,8 +69,19 @@ const InformationNode: BaseNodeComponent = (props) => { // Only consider additional height, by ignoring the height of the first row const additionalHeight = height - meta.rowHeight; - if (informationTextareaAdditionalHeight !== additionalHeight) { - setInformationTextareaAdditionalHeight(additionalHeight); + if (node?.data?.dynHeights?.informationTextareaHeight !== additionalHeight) { + const patchedNodeAdditionalData: Partial = { + dynHeights: { + ...node?.data?.dynHeights as QuestionNodeAdditionalData['dynHeights'], + informationTextareaHeight: additionalHeight, + }, + }; + + // Updates the value in the Recoil store + patchCurrentNode({ + data: patchedNodeAdditionalData, + height: calculateNodeHeight(patchedNodeAdditionalData.dynHeights), + } as InformationNodeData); } }; diff --git a/src/components/nodes/QuestionNode.tsx b/src/components/nodes/QuestionNode.tsx index 9b8f17d..29b8556 100644 --- a/src/components/nodes/QuestionNode.tsx +++ b/src/components/nodes/QuestionNode.tsx @@ -82,26 +82,6 @@ const QuestionNode: BaseNodeComponent = (props) => { (willDisplayChoiceInputs ? (dynHeights?.choicesBaseHeight || 0) : 0); }; - /** - * Calculates the node's height dynamically. - * - * The node's height is dynamic and depends on various parameters (selected option, length of text, etc.). - */ - // useEffect(() => { - // // TODO increase height depending on how many input choice there are (50px/unit)? - // const newHeight = calculateNodeHeight(node?.data?.dynHeights); - // console.log('node', node); - // console.log('newHeight', newHeight); - // - // // Only update the height if it's different - // if (node?.height !== newHeight) { - // console.log('useEffect updating height', newHeight, node?.height); - // patchCurrentNode({ - // height: newHeight, - // }); - // } - // }, [node?.data?.dynHeights, displayChoiceInputs]); - /** * When textarea input height changes, we need to increase the height of the whole node accordingly. * From f20a30b12c344e25e5fd3f8cd0d77842c21a0e97 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Mon, 15 Mar 2021 16:36:44 +0100 Subject: [PATCH 097/148] Fix height update --- src/components/nodes/InformationNode.tsx | 33 ++++++++++++----------- src/components/nodes/QuestionNode.tsx | 34 +++++++++++++----------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/components/nodes/InformationNode.tsx b/src/components/nodes/InformationNode.tsx index b5c0976..f388af1 100644 --- a/src/components/nodes/InformationNode.tsx +++ b/src/components/nodes/InformationNode.tsx @@ -68,19 +68,20 @@ const InformationNode: BaseNodeComponent = (props) => { const onInformationTextHeightChange = (height: number, meta: TextareaHeightChangeMeta) => { // Only consider additional height, by ignoring the height of the first row const additionalHeight = height - meta.rowHeight; + const patchedNodeAdditionalData: Partial = { + dynHeights: { + ...node?.data?.dynHeights as QuestionNodeAdditionalData['dynHeights'], + informationTextareaHeight: additionalHeight, + }, + }; + const newHeight = calculateNodeHeight(patchedNodeAdditionalData.dynHeights); + console.log('onTextHeightChange ', node?.data?.dynHeights?.informationTextareaHeight, newHeight); - if (node?.data?.dynHeights?.informationTextareaHeight !== additionalHeight) { - const patchedNodeAdditionalData: Partial = { - dynHeights: { - ...node?.data?.dynHeights as QuestionNodeAdditionalData['dynHeights'], - informationTextareaHeight: additionalHeight, - }, - }; - + if (node?.data?.dynHeights?.informationTextareaHeight !== newHeight) { // Updates the value in the Recoil store patchCurrentNode({ data: patchedNodeAdditionalData, - height: calculateNodeHeight(patchedNodeAdditionalData.dynHeights), + height: newHeight, } as InformationNodeData); } }; @@ -93,12 +94,14 @@ const InformationNode: BaseNodeComponent = (props) => { const onInformationTextInputValueChange = (event: any) => { const newValue = event.target.value; - // Updates the value in the Recoil store - patchCurrentNode({ - data: { - informationText: newValue, - }, - } as InformationNodeData); + if (newValue !== node?.data?.informationText) { + // Updates the value in the Recoil store + patchCurrentNode({ + data: { + informationText: newValue, + }, + } as InformationNodeData); + } }; return ( diff --git a/src/components/nodes/QuestionNode.tsx b/src/components/nodes/QuestionNode.tsx index 29b8556..ec27036 100644 --- a/src/components/nodes/QuestionNode.tsx +++ b/src/components/nodes/QuestionNode.tsx @@ -91,20 +91,20 @@ const QuestionNode: BaseNodeComponent = (props) => { const onQuestionInputHeightChange = (height: number, meta: TextareaHeightChangeMeta) => { // Only consider additional height, by ignoring the height of the first row const additionalHeight = height - meta.rowHeight; - console.log('onTextHeightChange ', node?.data?.dynHeights?.questionTextareaHeight, additionalHeight); - - if (node?.data?.dynHeights?.questionTextareaHeight !== additionalHeight) { - const patchedNodeAdditionalData: Partial = { - dynHeights: { - ...node?.data?.dynHeights as QuestionNodeAdditionalData['dynHeights'], - questionTextareaHeight: additionalHeight, - }, - }; + const patchedNodeAdditionalData: Partial = { + dynHeights: { + ...node?.data?.dynHeights as QuestionNodeAdditionalData['dynHeights'], + questionTextareaHeight: additionalHeight, + }, + }; + const newHeight = calculateNodeHeight(patchedNodeAdditionalData.dynHeights); + console.log('onTextHeightChange ', node?.data?.dynHeights?.questionTextareaHeight, newHeight); + if (node?.data?.dynHeights?.questionTextareaHeight !== newHeight) { // Updates the value in the Recoil store patchCurrentNode({ data: patchedNodeAdditionalData, - height: calculateNodeHeight(patchedNodeAdditionalData.dynHeights), + height: newHeight, } as QuestionNodeData); } }; @@ -117,12 +117,14 @@ const QuestionNode: BaseNodeComponent = (props) => { const onQuestionInputValueChange = (event: any) => { const newValue = event.target.value; - // Updates the value in the Recoil store - patchCurrentNode({ - data: { - questionText: newValue, - }, - } as QuestionNodeData); + if (newValue !== node?.data?.questionText) { + // Updates the value in the Recoil store + patchCurrentNode({ + data: { + questionText: newValue, + }, + } as QuestionNodeData); + } }; /** From e1069d8f7bcb684ad3c13e9353190d1c4cf7be66 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Mon, 15 Mar 2021 19:17:29 +0100 Subject: [PATCH 098/148] TS misc --- src/components/nodes/InformationNode.tsx | 6 ++---- src/components/nodes/QuestionNode.tsx | 15 ++++++++------- src/types/nodes/QuestionNodeAdditionalData.ts | 4 +++- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/components/nodes/InformationNode.tsx b/src/components/nodes/InformationNode.tsx index f388af1..874cbfb 100644 --- a/src/components/nodes/InformationNode.tsx +++ b/src/components/nodes/InformationNode.tsx @@ -7,7 +7,6 @@ import { BaseNodeDefaultProps } from '../../types/BaseNodeDefaultProps'; import BaseNodeProps from '../../types/BaseNodeProps'; import { InformationNodeAdditionalData } from '../../types/nodes/InformationNodeAdditionalData'; import { InformationNodeData } from '../../types/nodes/InformationNodeData'; -import { QuestionNodeAdditionalData } from '../../types/nodes/QuestionNodeAdditionalData'; import { SpecializedNodeProps } from '../../types/nodes/SpecializedNodeProps'; import NodeType from '../../types/NodeType'; import { isYoungerThan } from '../../utils/date'; @@ -70,12 +69,11 @@ const InformationNode: BaseNodeComponent = (props) => { const additionalHeight = height - meta.rowHeight; const patchedNodeAdditionalData: Partial = { dynHeights: { - ...node?.data?.dynHeights as QuestionNodeAdditionalData['dynHeights'], informationTextareaHeight: additionalHeight, - }, + } as InformationNodeAdditionalData['dynHeights'], }; const newHeight = calculateNodeHeight(patchedNodeAdditionalData.dynHeights); - console.log('onTextHeightChange ', node?.data?.dynHeights?.informationTextareaHeight, newHeight); + console.log('onTextHeightChange ', node?.data?.dynHeights?.informationTextareaHeight, newHeight, node?.data?.dynHeights?.informationTextareaHeight !== newHeight); if (node?.data?.dynHeights?.informationTextareaHeight !== newHeight) { // Updates the value in the Recoil store diff --git a/src/components/nodes/QuestionNode.tsx b/src/components/nodes/QuestionNode.tsx index ec27036..dc7eade 100644 --- a/src/components/nodes/QuestionNode.tsx +++ b/src/components/nodes/QuestionNode.tsx @@ -76,10 +76,11 @@ const QuestionNode: BaseNodeComponent = (props) => { * @param dynHeights * @param willDisplayChoiceInputs */ - const calculateNodeHeight = (dynHeights?: QuestionNodeAdditionalData['dynHeights'], willDisplayChoiceInputs: boolean = displayChoiceInputs): number => { + const calculateNodeHeight = (dynHeights: Partial, willDisplayChoiceInputs: boolean): number => { + console.log('calculateNodeHeight', dynHeights, willDisplayChoiceInputs) return (dynHeights?.baseHeight || baseHeight) + (dynHeights?.questionTextareaHeight || 0) + - (willDisplayChoiceInputs ? (dynHeights?.choicesBaseHeight || 0) : 0); + (willDisplayChoiceInputs ? (choiceBaseHeight || 0) : 0); }; /** @@ -93,12 +94,11 @@ const QuestionNode: BaseNodeComponent = (props) => { const additionalHeight = height - meta.rowHeight; const patchedNodeAdditionalData: Partial = { dynHeights: { - ...node?.data?.dynHeights as QuestionNodeAdditionalData['dynHeights'], questionTextareaHeight: additionalHeight, - }, + } as Partial, }; - const newHeight = calculateNodeHeight(patchedNodeAdditionalData.dynHeights); - console.log('onTextHeightChange ', node?.data?.dynHeights?.questionTextareaHeight, newHeight); + const newHeight = calculateNodeHeight(patchedNodeAdditionalData.dynHeights, displayChoiceInputs); + console.log('onTextHeightChange ', node?.data?.dynHeights?.questionTextareaHeight, newHeight, node?.data?.dynHeights?.questionTextareaHeight !== newHeight); if (node?.data?.dynHeights?.questionTextareaHeight !== newHeight) { // Updates the value in the Recoil store @@ -158,11 +158,12 @@ const QuestionNode: BaseNodeComponent = (props) => { choicesBaseHeight: willDisplayChoiceInputs ? choiceBaseHeight : 0, }, }; + const newHeight = calculateNodeHeight(patchedNodeAdditionalData.dynHeights, willDisplayChoiceInputs); // Updates the value in the Recoil store patchCurrentNodeImmediately({ data: patchedNodeAdditionalData, - height: calculateNodeHeight(patchedNodeAdditionalData.dynHeights, willDisplayChoiceInputs), + height: newHeight, } as QuestionNodeData); } }; diff --git a/src/types/nodes/QuestionNodeAdditionalData.ts b/src/types/nodes/QuestionNodeAdditionalData.ts index ce9fc0d..58d8ae8 100644 --- a/src/types/nodes/QuestionNodeAdditionalData.ts +++ b/src/types/nodes/QuestionNodeAdditionalData.ts @@ -65,7 +65,9 @@ export type QuestionNodeAdditionalData = BaseNodeAdditionalData & NodeDataWithVa * so the new feature will be visible immediately. */ dynHeights?: BaseNodeAdditionalData['dynHeights'] & { + /** + * Height of the "question" textarea input. + */ questionTextareaHeight?: number; - choicesBaseHeight?: number; } }; From d373bc34cddc0261e5c0f6f88aa64de95abb9573 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Mon, 15 Mar 2021 19:45:17 +0100 Subject: [PATCH 099/148] Handle concurrent patches better (WIP) --- src/components/nodes/QuestionNode.tsx | 31 ++++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/components/nodes/QuestionNode.tsx b/src/components/nodes/QuestionNode.tsx index dc7eade..7f2b5ab 100644 --- a/src/components/nodes/QuestionNode.tsx +++ b/src/components/nodes/QuestionNode.tsx @@ -1,5 +1,6 @@ import { Button } from '@chakra-ui/react'; import { css } from '@emotion/react'; +import merge from 'lodash.merge'; import now from 'lodash.now'; import sortBy from 'lodash.sortby'; import React, { Fragment } from 'react'; @@ -7,6 +8,7 @@ import { DebounceInput } from 'react-debounce-input'; import ReactSelect from 'react-select'; import { OptionTypeBase } from 'react-select/src/types'; import { TextareaHeightChangeMeta } from 'react-textarea-autosize/dist/declarations/src'; +import { useDebouncedCallback } from 'use-debounce'; import { v1 as uuid } from 'uuid'; import settings from '../../settings'; import BaseNodeComponent from '../../types/BaseNodeComponent'; @@ -70,6 +72,24 @@ const QuestionNode: BaseNodeComponent = (props) => { // Autofocus works fine when the node is inside the viewport, but when it's created outside it moves the viewport back at the beginning const shouldAutofocus = false && lastCreatedNode?.id === node.id && isYoungerThan(lastCreatedAt, 1000); // XXX Disabled for now, need a way to auto-center on the newly created node + const patches: Partial = {}; + + const _applyConcurrentPatches = useDebouncedCallback( + () => { + console.log('Applying patches', patches); + patchCurrentNode(patches); + }, + 1000, // Wait for other changes to happen, if no change happen then invoke the update + { + maxWait: 10000, + }, + ); + + const applyConcurrentPatches = (patch: Partial) => { + merge(patches, patches, patch); + _applyConcurrentPatches(); + }; + /** * Calculates the node's height based on the dynamic source that affect the dynamic height of the component. * @@ -77,7 +97,7 @@ const QuestionNode: BaseNodeComponent = (props) => { * @param willDisplayChoiceInputs */ const calculateNodeHeight = (dynHeights: Partial, willDisplayChoiceInputs: boolean): number => { - console.log('calculateNodeHeight', dynHeights, willDisplayChoiceInputs) + console.log('calculateNodeHeight', dynHeights, willDisplayChoiceInputs); return (dynHeights?.baseHeight || baseHeight) + (dynHeights?.questionTextareaHeight || 0) + (willDisplayChoiceInputs ? (choiceBaseHeight || 0) : 0); @@ -102,7 +122,7 @@ const QuestionNode: BaseNodeComponent = (props) => { if (node?.data?.dynHeights?.questionTextareaHeight !== newHeight) { // Updates the value in the Recoil store - patchCurrentNode({ + applyConcurrentPatches({ data: patchedNodeAdditionalData, height: newHeight, } as QuestionNodeData); @@ -118,8 +138,7 @@ const QuestionNode: BaseNodeComponent = (props) => { const newValue = event.target.value; if (newValue !== node?.data?.questionText) { - // Updates the value in the Recoil store - patchCurrentNode({ + applyConcurrentPatches({ data: { questionText: newValue, }, @@ -153,10 +172,6 @@ const QuestionNode: BaseNodeComponent = (props) => { const willDisplayChoiceInputs = selectedChoiceValue === 'single-quick-reply'; const patchedNodeAdditionalData: Partial = { questionChoiceType: selectedChoiceValue, - dynHeights: { - ...node?.data?.dynHeights as QuestionNodeAdditionalData['dynHeights'], - choicesBaseHeight: willDisplayChoiceInputs ? choiceBaseHeight : 0, - }, }; const newHeight = calculateNodeHeight(patchedNodeAdditionalData.dynHeights, willDisplayChoiceInputs); From ebc4bba8c84933a00634a7c9be474f0d2b6a277d Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Mon, 15 Mar 2021 20:03:21 +0100 Subject: [PATCH 100/148] Few minor improvements, better but still not perfect --- src/components/nodes/QuestionNode.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/nodes/QuestionNode.tsx b/src/components/nodes/QuestionNode.tsx index 7f2b5ab..c359e87 100644 --- a/src/components/nodes/QuestionNode.tsx +++ b/src/components/nodes/QuestionNode.tsx @@ -1,5 +1,6 @@ import { Button } from '@chakra-ui/react'; import { css } from '@emotion/react'; +import isEmpty from 'lodash.isempty'; import merge from 'lodash.merge'; import now from 'lodash.now'; import sortBy from 'lodash.sortby'; @@ -72,12 +73,15 @@ const QuestionNode: BaseNodeComponent = (props) => { // Autofocus works fine when the node is inside the viewport, but when it's created outside it moves the viewport back at the beginning const shouldAutofocus = false && lastCreatedNode?.id === node.id && isYoungerThan(lastCreatedAt, 1000); // XXX Disabled for now, need a way to auto-center on the newly created node - const patches: Partial = {}; + let concurrentPatches: Partial = {}; const _applyConcurrentPatches = useDebouncedCallback( () => { - console.log('Applying patches', patches); - patchCurrentNode(patches); + if (!isEmpty(concurrentPatches)) { + console.log('Applying concurrent patches as one consolidated patch', concurrentPatches); + patchCurrentNode(concurrentPatches); + concurrentPatches = {}; + } }, 1000, // Wait for other changes to happen, if no change happen then invoke the update { @@ -85,8 +89,8 @@ const QuestionNode: BaseNodeComponent = (props) => { }, ); - const applyConcurrentPatches = (patch: Partial) => { - merge(patches, patches, patch); + const patchCurrentNodeConcurrently = (patch: Partial) => { + merge(concurrentPatches, concurrentPatches, patch); _applyConcurrentPatches(); }; @@ -122,7 +126,7 @@ const QuestionNode: BaseNodeComponent = (props) => { if (node?.data?.dynHeights?.questionTextareaHeight !== newHeight) { // Updates the value in the Recoil store - applyConcurrentPatches({ + patchCurrentNodeConcurrently({ data: patchedNodeAdditionalData, height: newHeight, } as QuestionNodeData); @@ -136,9 +140,11 @@ const QuestionNode: BaseNodeComponent = (props) => { */ const onQuestionInputValueChange = (event: any) => { const newValue = event.target.value; + console.log('onQuestionInputValueChange', event); if (newValue !== node?.data?.questionText) { - applyConcurrentPatches({ + console.log('onQuestionInputValueChange ', newValue); + patchCurrentNodeConcurrently({ data: { questionText: newValue, }, From 600275c89906b9fb19986d2a1babb8c13a4a03cb Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Mon, 15 Mar 2021 20:20:53 +0100 Subject: [PATCH 101/148] Add patchCurrentNodeConcurrently helper for nodes --- src/components/nodes/BaseNode.tsx | 48 ++++++++++++++++++++++++- src/components/nodes/QuestionNode.tsx | 22 +----------- src/types/BaseNodeProps.ts | 1 + src/types/nodes/SpecializedNodeProps.ts | 18 ++++++++-- 4 files changed, 64 insertions(+), 25 deletions(-) diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index b614956..e993f2e 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -30,10 +30,14 @@ import BaseNodeAdditionalData from '../../types/BaseNodeAdditionalData'; import BaseNodeComponent from '../../types/BaseNodeComponent'; import BaseNodeData from '../../types/BaseNodeData'; import { BaseNodeDefaultProps } from '../../types/BaseNodeDefaultProps'; -import BaseNodeProps, { PatchCurrentNode } from '../../types/BaseNodeProps'; +import BaseNodeProps, { + PatchCurrentNode, + PatchCurrentNodeConcurrently, +} from '../../types/BaseNodeProps'; import BasePortData from '../../types/BasePortData'; import { CanvasDataset } from '../../types/CanvasDataset'; import { GetBaseNodeDefaultPropsProps } from '../../types/GetBaseNodeDefaultProps'; +import { QuestionNodeData } from '../../types/nodes/QuestionNodeData'; import { SpecializedNodeProps } from '../../types/nodes/SpecializedNodeProps'; import NodeType from '../../types/NodeType'; import PartialBaseNodeData from '../../types/PartialBaseNodeData'; @@ -107,6 +111,47 @@ const BaseNode: BaseNodeComponent = (props) => { // Particularly useful to the editor when ELK changes the nodes position to avoid losing track of the node that was just created const [isRecentlyCreated, setIsRecentlyCreated] = useState(isYoungerThan(lastCreatedAt, recentlyCreatedMaxAge)); + // Contains concurrent patches as an unified/consolidated patch + let consolidatedPatches: Partial = {}; + + /** + * Applies the consolidated patches. + * + * This function is debounced, and will not be executed unless there were no more patches applied, or the maxWait has been reached. + */ + const _applyConcurrentPatches = useDebouncedCallback( + () => { + if (!isEmpty(consolidatedPatches)) { + console.log('Applying concurrent patches as one consolidated patch', consolidatedPatches); + patchCurrentNode(consolidatedPatches); + + // Reset for the next consolidated patches + consolidatedPatches = {}; + } + }, + 1000, // Wait for other changes to happen, if no change happen then invoke the update + { + maxWait: 10000, + }, + ); + + /** + * Help consolidating multiple concurrent patches of the same node as one consolidated patch. + * + * When several events are fired at the same time and they all update the current node, it will be unpredictable to know which event will take precedence. + * in such case, it is necessary to have a temporary "consolidated patches" object that contains all updates and is executed once all patches have been merged. + * + * @param patch + */ + const patchCurrentNodeConcurrently: PatchCurrentNodeConcurrently = (patch: PartialBaseNodeData) => { + // Merge the current patch to the consolidated concurrent patch object + merge(consolidatedPatches, consolidatedPatches, patch); + console.log('Patch applied, patch:', patch, 'result:', consolidatedPatches) + + // Apply the concurrent patches, this call will be debounced and won't take effect immediately + _applyConcurrentPatches(); + }; + /** * Debounces the patchCurrentNode function. * @@ -365,6 +410,7 @@ const BaseNode: BaseNodeComponent = (props) => { lastCreated, patchCurrentNode: debouncedPatchCurrentNode, patchCurrentNodeImmediately: patchCurrentNode, + patchCurrentNodeConcurrently, }; return ( diff --git a/src/components/nodes/QuestionNode.tsx b/src/components/nodes/QuestionNode.tsx index c359e87..7e50a9b 100644 --- a/src/components/nodes/QuestionNode.tsx +++ b/src/components/nodes/QuestionNode.tsx @@ -63,6 +63,7 @@ const QuestionNode: BaseNodeComponent = (props) => { lastCreated, patchCurrentNode, patchCurrentNodeImmediately, + patchCurrentNodeConcurrently, } = nodeProps; const choiceTypes: QuestionChoiceTypeOption[] = settings.canvas.nodes.questionNode.choiceTypeOptions; const lastCreatedNode = lastCreated?.node; @@ -73,27 +74,6 @@ const QuestionNode: BaseNodeComponent = (props) => { // Autofocus works fine when the node is inside the viewport, but when it's created outside it moves the viewport back at the beginning const shouldAutofocus = false && lastCreatedNode?.id === node.id && isYoungerThan(lastCreatedAt, 1000); // XXX Disabled for now, need a way to auto-center on the newly created node - let concurrentPatches: Partial = {}; - - const _applyConcurrentPatches = useDebouncedCallback( - () => { - if (!isEmpty(concurrentPatches)) { - console.log('Applying concurrent patches as one consolidated patch', concurrentPatches); - patchCurrentNode(concurrentPatches); - concurrentPatches = {}; - } - }, - 1000, // Wait for other changes to happen, if no change happen then invoke the update - { - maxWait: 10000, - }, - ); - - const patchCurrentNodeConcurrently = (patch: Partial) => { - merge(concurrentPatches, concurrentPatches, patch); - _applyConcurrentPatches(); - }; - /** * Calculates the node's height based on the dynamic source that affect the dynamic height of the component. * diff --git a/src/types/BaseNodeProps.ts b/src/types/BaseNodeProps.ts index b7d59de..1528d43 100644 --- a/src/types/BaseNodeProps.ts +++ b/src/types/BaseNodeProps.ts @@ -3,6 +3,7 @@ import BaseNodeData from './BaseNodeData'; import PartialBaseNodeData from './PartialBaseNodeData'; export type PatchCurrentNode = Partial> = (patch: PartialBaseNodeData) => void; +export type PatchCurrentNodeConcurrently = Partial> = (patch: PartialBaseNodeData) => void; /** * Props received by any *Node component (InformationNode, etc.). diff --git a/src/types/nodes/SpecializedNodeProps.ts b/src/types/nodes/SpecializedNodeProps.ts index a6124e5..d25a178 100644 --- a/src/types/nodes/SpecializedNodeProps.ts +++ b/src/types/nodes/SpecializedNodeProps.ts @@ -1,6 +1,9 @@ import { NodeChildProps } from 'reaflow'; import BaseNodeData from '../BaseNodeData'; -import { PatchCurrentNode } from '../BaseNodeProps'; +import { + PatchCurrentNode, + PatchCurrentNodeConcurrently, +} from '../BaseNodeProps'; import { LastCreated } from '../LastCreated'; /** @@ -15,8 +18,7 @@ export type SpecializedNodeProps = /** * Path the properties of the current node. * - * Only updates the provided properties, doesn't update other properties. - * Also merges the 'data' object, by keeping existing data and only overwriting those that are specified. + * Only updates the provided properties (deep merge), doesn't update other properties. * * @param nodeData */ @@ -31,6 +33,16 @@ export type SpecializedNodeProps = */ patchCurrentNodeImmediately: PatchCurrentNode>; + /** + * Similar to patchCurrentNode, but is being debounced and will not execute immediately. + * + * Calling multiple times this function in a short amount of time will consolidate a patch together. + * Eventually, the consolidated patch will be executed as one change instead of multiple changes. + * + * @param nodeData + */ + patchCurrentNodeConcurrently: PatchCurrentNodeConcurrently>; + /** * The last created node and its time of creation. * Will be undefined if no node was created yet. From fb037c4c50227c4818905e3c5029edd8982d4e01 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Mon, 15 Mar 2021 20:24:34 +0100 Subject: [PATCH 102/148] Adapt InformationNode --- src/components/nodes/InformationNode.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/nodes/InformationNode.tsx b/src/components/nodes/InformationNode.tsx index 874cbfb..ee34d71 100644 --- a/src/components/nodes/InformationNode.tsx +++ b/src/components/nodes/InformationNode.tsx @@ -41,6 +41,7 @@ const InformationNode: BaseNodeComponent = (props) => { node, lastCreated, patchCurrentNode, + patchCurrentNodeConcurrently, } = nodeProps; const lastCreatedNode = lastCreated?.node; const lastCreatedAt = lastCreated?.at; @@ -77,7 +78,7 @@ const InformationNode: BaseNodeComponent = (props) => { if (node?.data?.dynHeights?.informationTextareaHeight !== newHeight) { // Updates the value in the Recoil store - patchCurrentNode({ + patchCurrentNodeConcurrently({ data: patchedNodeAdditionalData, height: newHeight, } as InformationNodeData); @@ -94,7 +95,7 @@ const InformationNode: BaseNodeComponent = (props) => { if (newValue !== node?.data?.informationText) { // Updates the value in the Recoil store - patchCurrentNode({ + patchCurrentNodeConcurrently({ data: { informationText: newValue, }, From ba2ce2264bf1dc7ba5c38772b7a50ed28e962e1d Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Mon, 15 Mar 2021 20:44:29 +0100 Subject: [PATCH 103/148] Dynamically auto-recalculate actual width/height when base changes (instead of waiting for next page refresh) --- src/components/nodes/BaseNode.tsx | 23 ++++++++++++++++++----- src/components/nodes/InformationNode.tsx | 1 - src/components/nodes/QuestionNode.tsx | 5 +---- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index e993f2e..a7ef3ee 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -146,7 +146,7 @@ const BaseNode: BaseNodeComponent = (props) => { const patchCurrentNodeConcurrently: PatchCurrentNodeConcurrently = (patch: PartialBaseNodeData) => { // Merge the current patch to the consolidated concurrent patch object merge(consolidatedPatches, consolidatedPatches, patch); - console.log('Patch applied, patch:', patch, 'result:', consolidatedPatches) + console.log('Patch applied, patch:', patch, 'result:', consolidatedPatches); // Apply the concurrent patches, this call will be debounced and won't take effect immediately _applyConcurrentPatches(); @@ -187,30 +187,43 @@ const BaseNode: BaseNodeComponent = (props) => { */ useEffect(() => { const patchData: Partial = {}; + const patch: PartialBaseNodeData = { + data: patchData, + }; if (node?.data?.dynHeights?.baseHeight !== baseHeight) { patchData.dynHeights = { baseHeight: baseHeight, }; + + if (node?.data?.dynHeights?.baseHeight && node?.height) { + const heightDiff = node?.data?.dynHeights?.baseHeight - baseHeight; + patch.height = node?.height - heightDiff; + } } if (node?.data?.dynWidths?.baseWidth !== baseWidth) { patchData.dynWidths = { baseWidth: baseWidth, }; + + if (node?.data?.dynWidths?.baseWidth && node?.width) { + const widthDiff = node?.data?.dynWidths?.baseWidth - baseWidth; + patch.width = node?.width - widthDiff; + } } if (!isEmpty(patchData)) { console.log(`Current node's base width/height doesn't match component's own base width/height. Updating the current node with patch:`, patchData); - debouncedPatchCurrentNode({ - data: patchData, - }); + patchCurrentNodeConcurrently(patch); } }, [ - baseWidth, baseHeight, + baseWidth, node?.height, + node?.width, node?.data?.dynHeights?.baseHeight, + node?.data?.dynWidths?.baseWidth, ]); /** diff --git a/src/components/nodes/InformationNode.tsx b/src/components/nodes/InformationNode.tsx index ee34d71..72aa3a1 100644 --- a/src/components/nodes/InformationNode.tsx +++ b/src/components/nodes/InformationNode.tsx @@ -40,7 +40,6 @@ const InformationNode: BaseNodeComponent = (props) => { const { node, lastCreated, - patchCurrentNode, patchCurrentNodeConcurrently, } = nodeProps; const lastCreatedNode = lastCreated?.node; diff --git a/src/components/nodes/QuestionNode.tsx b/src/components/nodes/QuestionNode.tsx index 7e50a9b..b9e3caa 100644 --- a/src/components/nodes/QuestionNode.tsx +++ b/src/components/nodes/QuestionNode.tsx @@ -1,7 +1,5 @@ import { Button } from '@chakra-ui/react'; import { css } from '@emotion/react'; -import isEmpty from 'lodash.isempty'; -import merge from 'lodash.merge'; import now from 'lodash.now'; import sortBy from 'lodash.sortby'; import React, { Fragment } from 'react'; @@ -9,7 +7,6 @@ import { DebounceInput } from 'react-debounce-input'; import ReactSelect from 'react-select'; import { OptionTypeBase } from 'react-select/src/types'; import { TextareaHeightChangeMeta } from 'react-textarea-autosize/dist/declarations/src'; -import { useDebouncedCallback } from 'use-debounce'; import { v1 as uuid } from 'uuid'; import settings from '../../settings'; import BaseNodeComponent from '../../types/BaseNodeComponent'; @@ -34,7 +31,7 @@ type Props = {} & BaseNodeProps; const nodeType: NodeType = 'question'; const baseWidth = 250; -const baseHeight = 320; +const baseHeight = 300; /** * Question node. From 25e8c2fab363a90656466022b3cece23c5a705ee Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 11:09:09 +0100 Subject: [PATCH 104/148] Always add borderWidth to nodes to avoid content jump when selected --- src/components/nodes/BaseNode.tsx | 3 ++- src/settings.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index a7ef3ee..5e3ec75 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -440,10 +440,11 @@ const BaseNode: BaseNodeComponent = (props) => { // y={0} // Relative position from the parent Node component (aligned to left) css={css` position: relative; + border: ${settings.canvas.nodes.borderWidth}px solid transparent; // Highlights the node when it's being selected &.is-selected { - border: 2px solid ${isReachable ? settings.canvas.nodes.selected.borderColor : 'orange'}; + border: ${settings.canvas.nodes.borderWidth}px solid ${isReachable ? settings.canvas.nodes.selected.borderColor : 'orange'}; border-radius: 2px; } diff --git a/src/settings.ts b/src/settings.ts index d06263e..97ddafb 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -22,6 +22,7 @@ export const settings: Settings = { maxHeight: 2000, nodes: { defaultDebounceWaitFor: 500, + borderWidth: 2, selected: { borderColor: 'rgba(0, 40, 255, 0.2)', }, @@ -119,6 +120,13 @@ export type CanvasSettings = { */ defaultDebounceWaitFor: number; + /** + * Width of the border, in pixels. + * + * The nodes always have a border, which changes of color when the node's selected. + */ + borderWidth: 2; + selected: { /** * Border color when the node is selected. From 0783073d21c25992792685bd06dabc4aed45071b Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 14:46:18 +0100 Subject: [PATCH 105/148] Added a changes queue to apply all changes at once as one consolidated update (WIP) --- src/components/editor/CanvasContainer.tsx | 86 +++++++++++++++++++++++ src/components/nodes/BaseNode.tsx | 80 ++++----------------- src/components/nodes/InformationNode.tsx | 6 +- src/components/nodes/NodeRouter.tsx | 7 +- src/components/nodes/QuestionNode.tsx | 5 +- src/types/BaseNodeProps.ts | 6 ++ src/types/CanvasDatasetMutation.ts | 49 +++++++++++++ src/types/nodes/SpecializedNodeProps.ts | 22 +++--- 8 files changed, 174 insertions(+), 87 deletions(-) create mode 100644 src/types/CanvasDatasetMutation.ts diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index d061c84..bc107d5 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -2,6 +2,8 @@ import { Button } from '@chakra-ui/react'; import { css } from '@emotion/react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { isBrowser } from '@unly/utils'; +import cloneDeep from 'lodash.clonedeep'; +import merge from 'lodash.merge'; import React, { MutableRefObject, useEffect, @@ -17,6 +19,7 @@ import { } from 'reaflow'; import { useRecoilState } from 'recoil'; 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'; @@ -26,8 +29,13 @@ import { canvasDatasetSelector } from '../../states/canvasDatasetSelector'; import { selectedEdgesSelector } from '../../states/selectedEdgesState'; import { selectedNodesSelector } from '../../states/selectedNodesState'; import { UserSession } from '../../types/auth/UserSession'; +import BaseEdgeData from '../../types/BaseEdgeData'; import BaseNodeData from '../../types/BaseNodeData'; import { CanvasDataset } from '../../types/CanvasDataset'; +import { + AddCanvasDatasetMutation, + CanvasDatasetMutation, +} from '../../types/CanvasDatasetMutation'; import { TypeOfRef } from '../../types/faunadb/TypeOfRef'; import { onInit, @@ -53,6 +61,8 @@ type Props = { canvasRef: MutableRefObject; } +const mutations: CanvasDatasetMutation[] = []; + /** * Canvas container. * @@ -108,6 +118,81 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n const edges = canvasDataset?.edges; const previousCanvasDataset: CanvasDataset | undefined = usePreviousValue(canvasDataset); + /** + * Apply all patches that haven't been applied yet. + */ + useEffect(() => { + // Only consider mutations that are waiting + const mutationsToApply = mutations.filter((mutation: CanvasDatasetMutation) => mutation.status === 'waiting'); + console.log(`patchesToApply (${mutationsToApply?.length})`, mutationsToApply); + + if (mutationsToApply?.length > 0) { + const newNodes: BaseNodeData[] = cloneDeep(nodes); + const newEdges: BaseEdgeData[] = cloneDeep(edges); + + // Mark all patches as being processed + mutationsToApply.map((mutation: CanvasDatasetMutation) => { + const { id } = mutation; + const patchIndex: number = mutations.findIndex((mutation: CanvasDatasetMutation) => mutation.id === id); + + if (patchIndex >= 0) { + mutations[patchIndex].status = 'processing'; + } + }); + console.log(`patchesToApply (processing (${mutationsToApply?.length}))`, mutationsToApply); + + // Processing all waiting patches into one consolidated update + mutationsToApply.map((mutation: CanvasDatasetMutation) => { + const { + elementId, + elementType, + operationType, + changes, + } = mutation; + + if (elementType === 'node') { + if (operationType === 'patch') { + const nodeToUpdateIndex: number = nodes.findIndex((node: BaseNodeData) => node.id === elementId); + const existingNode: BaseNodeData | undefined = nodes?.find((node: BaseNodeData) => node?.id === elementId); + const nodeToUpdate: BaseNodeData = {} as BaseNodeData; + + if (typeof existingNode !== 'undefined') { + merge(nodeToUpdate, existingNode, changes); + newNodes[nodeToUpdateIndex] = nodeToUpdate; + } else { + console.log(`Couldn't find node to patch with id "${nodeToUpdateIndex}".`); + } + } else { + console.error(`Not implemented ${operationType}`); + } + } else { + console.error(`Not implemented ${elementType}`); + } + }); + + setCanvasDataset({ + nodes: newNodes, + edges: newEdges, + }); + } + }); + + /** + * Add a new patch to apply to the existing queue. + * + * @param patch + */ + const addCanvasDatasetPatch: AddCanvasDatasetMutation = (patch) => { + mutations.push({ + status: 'waiting', + id: uuid(), + elementId: patch.elementId, + elementType: patch.elementType, + operationType: patch.operationType, + changes: patch.changes, + }); + }; + /** * Debounces the database update invocation call. * @@ -262,6 +347,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n return ( ); }; diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index 5e3ec75..618f87b 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -1,10 +1,8 @@ import { css } from '@emotion/react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classnames from 'classnames'; -import cloneDeep from 'lodash.clonedeep'; import includes from 'lodash.includes'; import isEmpty from 'lodash.isempty'; -import merge from 'lodash.merge'; import remove from 'lodash.remove'; import React, { KeyboardEventHandler, @@ -30,14 +28,11 @@ import BaseNodeAdditionalData from '../../types/BaseNodeAdditionalData'; import BaseNodeComponent from '../../types/BaseNodeComponent'; import BaseNodeData from '../../types/BaseNodeData'; import { BaseNodeDefaultProps } from '../../types/BaseNodeDefaultProps'; -import BaseNodeProps, { - PatchCurrentNode, - PatchCurrentNodeConcurrently, -} from '../../types/BaseNodeProps'; +import BaseNodeProps, { PatchCurrentNode } from '../../types/BaseNodeProps'; import BasePortData from '../../types/BasePortData'; import { CanvasDataset } from '../../types/CanvasDataset'; +import { NewCanvasDatasetMutation } from '../../types/CanvasDatasetMutation'; import { GetBaseNodeDefaultPropsProps } from '../../types/GetBaseNodeDefaultProps'; -import { QuestionNodeData } from '../../types/nodes/QuestionNodeData'; import { SpecializedNodeProps } from '../../types/nodes/SpecializedNodeProps'; import NodeType from '../../types/NodeType'; import PartialBaseNodeData from '../../types/PartialBaseNodeData'; @@ -90,6 +85,7 @@ const BaseNode: BaseNodeComponent = (props) => { baseHeight, patchCurrentNodeWait, patchCurrentNodeOptions, + addCanvasDatasetPatch, ...nodeProps // All props that are left will be forwarded to the Node component } = props; @@ -111,47 +107,6 @@ const BaseNode: BaseNodeComponent = (props) => { // Particularly useful to the editor when ELK changes the nodes position to avoid losing track of the node that was just created const [isRecentlyCreated, setIsRecentlyCreated] = useState(isYoungerThan(lastCreatedAt, recentlyCreatedMaxAge)); - // Contains concurrent patches as an unified/consolidated patch - let consolidatedPatches: Partial = {}; - - /** - * Applies the consolidated patches. - * - * This function is debounced, and will not be executed unless there were no more patches applied, or the maxWait has been reached. - */ - const _applyConcurrentPatches = useDebouncedCallback( - () => { - if (!isEmpty(consolidatedPatches)) { - console.log('Applying concurrent patches as one consolidated patch', consolidatedPatches); - patchCurrentNode(consolidatedPatches); - - // Reset for the next consolidated patches - consolidatedPatches = {}; - } - }, - 1000, // Wait for other changes to happen, if no change happen then invoke the update - { - maxWait: 10000, - }, - ); - - /** - * Help consolidating multiple concurrent patches of the same node as one consolidated patch. - * - * When several events are fired at the same time and they all update the current node, it will be unpredictable to know which event will take precedence. - * in such case, it is necessary to have a temporary "consolidated patches" object that contains all updates and is executed once all patches have been merged. - * - * @param patch - */ - const patchCurrentNodeConcurrently: PatchCurrentNodeConcurrently = (patch: PartialBaseNodeData) => { - // Merge the current patch to the consolidated concurrent patch object - merge(consolidatedPatches, consolidatedPatches, patch); - console.log('Patch applied, patch:', patch, 'result:', consolidatedPatches); - - // Apply the concurrent patches, this call will be debounced and won't take effect immediately - _applyConcurrentPatches(); - }; - /** * Debounces the patchCurrentNode function. * @@ -168,7 +123,7 @@ const BaseNode: BaseNodeComponent = (props) => { patchCurrentNode(patch); }, patchCurrentNodeWait || settings.canvas.nodes.defaultDebounceWaitFor, // Wait for other changes to happen, if no change happen then invoke the update - patchCurrentNodeOptions || {}, + patchCurrentNodeOptions || {}, ); /** @@ -215,7 +170,7 @@ const BaseNode: BaseNodeComponent = (props) => { if (!isEmpty(patchData)) { console.log(`Current node's base width/height doesn't match component's own base width/height. Updating the current node with patch:`, patchData); - patchCurrentNodeConcurrently(patch); + patchCurrentNode(patch); } }, [ baseHeight, @@ -234,25 +189,18 @@ const BaseNode: BaseNodeComponent = (props) => { * * XXX This function is being debounced by default (when used by children components) to avoid sending a burst of updates to the database. * - * XXX TLDR; Don't use "patchCurrentNode" multiple times in the same function, it won't work as expected: - * Make sure to call this function once per function call, otherwise only the last patch call would be persisted correctly - * (multiple calls within the same function would be overridden by the last patch, - * because the "node" used as reference wouldn't be updated right away and would still use the same (outdated) reference) - * * @param patch */ const patchCurrentNode: PatchCurrentNode = (patch: PartialBaseNodeData): void => { - const nodeToUpdateIndex = nodes.findIndex((node: BaseNodeData) => node.id === nodeProps.id); - const existingNode: BaseNodeData = nodes[nodeToUpdateIndex]; - const nodeToUpdate = {}; - merge(nodeToUpdate, existingNode, patch); - console.log('patchCurrentNode before', existingNode, 'after:', nodeToUpdate, 'using patch:', patch); - - const newNodes = cloneDeep(nodes); - // @ts-ignore - newNodes[nodeToUpdateIndex] = nodeToUpdate; + const mutation: NewCanvasDatasetMutation = { + operationType: 'patch', + elementId: node?.id, + elementType: 'node', + changes: patch, + }; - setNodes(newNodes); + console.log('Adding patch to the queue', 'patch:', patch, 'mutation:', mutation); + addCanvasDatasetPatch(mutation); }; /** @@ -423,7 +371,7 @@ const BaseNode: BaseNodeComponent = (props) => { lastCreated, patchCurrentNode: debouncedPatchCurrentNode, patchCurrentNodeImmediately: patchCurrentNode, - patchCurrentNodeConcurrently, + addCanvasDatasetPatch, }; return ( diff --git a/src/components/nodes/InformationNode.tsx b/src/components/nodes/InformationNode.tsx index 72aa3a1..874cbfb 100644 --- a/src/components/nodes/InformationNode.tsx +++ b/src/components/nodes/InformationNode.tsx @@ -40,7 +40,7 @@ const InformationNode: BaseNodeComponent = (props) => { const { node, lastCreated, - patchCurrentNodeConcurrently, + patchCurrentNode, } = nodeProps; const lastCreatedNode = lastCreated?.node; const lastCreatedAt = lastCreated?.at; @@ -77,7 +77,7 @@ const InformationNode: BaseNodeComponent = (props) => { if (node?.data?.dynHeights?.informationTextareaHeight !== newHeight) { // Updates the value in the Recoil store - patchCurrentNodeConcurrently({ + patchCurrentNode({ data: patchedNodeAdditionalData, height: newHeight, } as InformationNodeData); @@ -94,7 +94,7 @@ const InformationNode: BaseNodeComponent = (props) => { if (newValue !== node?.data?.informationText) { // Updates the value in the Recoil store - patchCurrentNodeConcurrently({ + patchCurrentNode({ data: { informationText: newValue, }, diff --git a/src/components/nodes/NodeRouter.tsx b/src/components/nodes/NodeRouter.tsx index 3cb0b5e..8b6b288 100644 --- a/src/components/nodes/NodeRouter.tsx +++ b/src/components/nodes/NodeRouter.tsx @@ -2,11 +2,14 @@ import React from 'react'; import { NodeProps } from 'reaflow'; import { useRecoilState } from 'recoil'; import { nodesSelector } from '../../states/nodesState'; +import BaseNodeComponent from '../../types/BaseNodeComponent'; import BaseNodeData from '../../types/BaseNodeData'; +import { AddCanvasDatasetMutation } from '../../types/CanvasDatasetMutation'; import { findNodeComponentByType } from '../../utils/nodes'; type Props = { nodeProps: NodeProps; + addCanvasDatasetPatch: AddCanvasDatasetMutation; } /** @@ -17,6 +20,7 @@ type Props = { const NodeRouter: React.FunctionComponent = (props) => { const { nodeProps, + addCanvasDatasetPatch, } = props; const nodeType = nodeProps?.properties?.data?.type; const [nodes, setNodes] = useRecoilState(nodesSelector); @@ -40,12 +44,13 @@ const NodeRouter: React.FunctionComponent = (props) => { } // Will render a specialized node (e.g: StartNode, etc.) - const NodeComponent = findNodeComponentByType(nodeType); + const NodeComponent: BaseNodeComponent = findNodeComponentByType(nodeType); return ( ); }; diff --git a/src/components/nodes/QuestionNode.tsx b/src/components/nodes/QuestionNode.tsx index b9e3caa..669267a 100644 --- a/src/components/nodes/QuestionNode.tsx +++ b/src/components/nodes/QuestionNode.tsx @@ -60,7 +60,6 @@ const QuestionNode: BaseNodeComponent = (props) => { lastCreated, patchCurrentNode, patchCurrentNodeImmediately, - patchCurrentNodeConcurrently, } = nodeProps; const choiceTypes: QuestionChoiceTypeOption[] = settings.canvas.nodes.questionNode.choiceTypeOptions; const lastCreatedNode = lastCreated?.node; @@ -103,7 +102,7 @@ const QuestionNode: BaseNodeComponent = (props) => { if (node?.data?.dynHeights?.questionTextareaHeight !== newHeight) { // Updates the value in the Recoil store - patchCurrentNodeConcurrently({ + patchCurrentNode({ data: patchedNodeAdditionalData, height: newHeight, } as QuestionNodeData); @@ -121,7 +120,7 @@ const QuestionNode: BaseNodeComponent = (props) => { if (newValue !== node?.data?.questionText) { console.log('onQuestionInputValueChange ', newValue); - patchCurrentNodeConcurrently({ + patchCurrentNode({ data: { questionText: newValue, }, diff --git a/src/types/BaseNodeProps.ts b/src/types/BaseNodeProps.ts index 1528d43..3234c88 100644 --- a/src/types/BaseNodeProps.ts +++ b/src/types/BaseNodeProps.ts @@ -1,5 +1,6 @@ import { NodeProps } from 'reaflow'; import BaseNodeData from './BaseNodeData'; +import { AddCanvasDatasetMutation } from './CanvasDatasetMutation'; import PartialBaseNodeData from './PartialBaseNodeData'; export type PatchCurrentNode = Partial> = (patch: PartialBaseNodeData) => void; @@ -13,6 +14,11 @@ export type BaseNodeProps = { * Current node. */ node: NodeData; + + /** + * TODO + */ + addCanvasDatasetPatch: AddCanvasDatasetMutation; } & Partial; export default BaseNodeProps; diff --git a/src/types/CanvasDatasetMutation.ts b/src/types/CanvasDatasetMutation.ts new file mode 100644 index 0000000..ec35b60 --- /dev/null +++ b/src/types/CanvasDatasetMutation.ts @@ -0,0 +1,49 @@ +import BaseEdgeData from './BaseEdgeData'; +import PartialBaseNodeData from './PartialBaseNodeData'; + +export type NewCanvasDatasetMutation = Omit +export type AddCanvasDatasetMutation = (mutation: NewCanvasDatasetMutation) => void; + +export type CanvasDatasetMutation = { + /** + * ID of the mutation, must be unique. + * + * UUID (v1). + */ + id: string; + + /** + * Status of the mutation. + * + * - applied: Has been applied. + * - processing: Is being processed. + * - waiting: Has not been processed yet, awaiting for the next batch of mutations. + */ + status: 'applied' | 'processing' | 'waiting'; + + /** + * The ID of the node/edge to mutate. + */ + elementId: string; + + /** + * The type of the element to mutate. + */ + elementType: 'node' | 'edge'; + + /** + * Operation to perform on the element. + * + * - add: Add a new element. + * - patch: Update an existing element. Only updates the specified properties of the element, leaving other properties untouched. + * - delete: Delete and existing element. + */ + operationType: 'add' | 'patch' | 'delete'; + + /** + * Patch to apply to the element. + * + * Only set for "update" operations. + */ + changes?: PartialBaseNodeData | Partial +} diff --git a/src/types/nodes/SpecializedNodeProps.ts b/src/types/nodes/SpecializedNodeProps.ts index d25a178..a2a7f6c 100644 --- a/src/types/nodes/SpecializedNodeProps.ts +++ b/src/types/nodes/SpecializedNodeProps.ts @@ -1,9 +1,7 @@ import { NodeChildProps } from 'reaflow'; import BaseNodeData from '../BaseNodeData'; -import { - PatchCurrentNode, - PatchCurrentNodeConcurrently, -} from '../BaseNodeProps'; +import { PatchCurrentNode } from '../BaseNodeProps'; +import { AddCanvasDatasetMutation } from '../CanvasDatasetMutation'; import { LastCreated } from '../LastCreated'; /** @@ -33,16 +31,6 @@ export type SpecializedNodeProps = */ patchCurrentNodeImmediately: PatchCurrentNode>; - /** - * Similar to patchCurrentNode, but is being debounced and will not execute immediately. - * - * Calling multiple times this function in a short amount of time will consolidate a patch together. - * Eventually, the consolidated patch will be executed as one change instead of multiple changes. - * - * @param nodeData - */ - patchCurrentNodeConcurrently: PatchCurrentNodeConcurrently>; - /** * The last created node and its time of creation. * Will be undefined if no node was created yet. @@ -58,4 +46,10 @@ export type SpecializedNodeProps = * Whether the node can be reached. */ isReachable: boolean; + + /** + * TODO + */ + addCanvasDatasetPatch: AddCanvasDatasetMutation; + } From a907b76610c00d99a07997b16ec8e8bfbfc9c0d7 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 15:26:40 +0100 Subject: [PATCH 106/148] Fix mutations --- src/components/editor/CanvasContainer.tsx | 19 ++++++++++++------- src/components/nodes/BaseNode.tsx | 4 ++-- src/components/nodes/InformationNode.tsx | 1 + src/types/CanvasDatasetMutation.ts | 4 ++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index bc107d5..1bc9e7e 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -147,18 +147,19 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n elementId, elementType, operationType, - changes, + patch, } = mutation; if (elementType === 'node') { if (operationType === 'patch') { - const nodeToUpdateIndex: number = nodes.findIndex((node: BaseNodeData) => node.id === elementId); - const existingNode: BaseNodeData | undefined = nodes?.find((node: BaseNodeData) => node?.id === elementId); - const nodeToUpdate: BaseNodeData = {} as BaseNodeData; + const nodeToUpdateIndex: number = newNodes.findIndex((node: BaseNodeData) => node.id === elementId); + const existingNode: BaseNodeData | undefined = newNodes.find((node: BaseNodeData) => node?.id === elementId); + const patchedNode: BaseNodeData = {} as BaseNodeData; if (typeof existingNode !== 'undefined') { - merge(nodeToUpdate, existingNode, changes); - newNodes[nodeToUpdateIndex] = nodeToUpdate; + merge(patchedNode, existingNode, patch); + console.log('Applying patch:', patch, 'to node:', existingNode, 'result:', patchedNode); + newNodes[nodeToUpdateIndex] = patchedNode; } else { console.log(`Couldn't find node to patch with id "${nodeToUpdateIndex}".`); } @@ -170,6 +171,10 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n } }); + console.log('Saving new dataset (batch)', { + nodes: newNodes, + edges: newEdges, + }); setCanvasDataset({ nodes: newNodes, edges: newEdges, @@ -189,7 +194,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n elementId: patch.elementId, elementType: patch.elementType, operationType: patch.operationType, - changes: patch.changes, + patch: patch.patch, }); }; diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index 618f87b..f543948 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -196,7 +196,7 @@ const BaseNode: BaseNodeComponent = (props) => { operationType: 'patch', elementId: node?.id, elementType: 'node', - changes: patch, + patch: patch, }; console.log('Adding patch to the queue', 'patch:', patch, 'mutation:', mutation); @@ -369,7 +369,7 @@ const BaseNode: BaseNodeComponent = (props) => { isSelected, isReachable, lastCreated, - patchCurrentNode: debouncedPatchCurrentNode, + patchCurrentNode, patchCurrentNodeImmediately: patchCurrentNode, addCanvasDatasetPatch, }; diff --git a/src/components/nodes/InformationNode.tsx b/src/components/nodes/InformationNode.tsx index 874cbfb..450784a 100644 --- a/src/components/nodes/InformationNode.tsx +++ b/src/components/nodes/InformationNode.tsx @@ -76,6 +76,7 @@ const InformationNode: BaseNodeComponent = (props) => { console.log('onTextHeightChange ', node?.data?.dynHeights?.informationTextareaHeight, newHeight, node?.data?.dynHeights?.informationTextareaHeight !== newHeight); if (node?.data?.dynHeights?.informationTextareaHeight !== newHeight) { + console.log('onTextHeightChange updating height') // Updates the value in the Recoil store patchCurrentNode({ data: patchedNodeAdditionalData, diff --git a/src/types/CanvasDatasetMutation.ts b/src/types/CanvasDatasetMutation.ts index ec35b60..933b16d 100644 --- a/src/types/CanvasDatasetMutation.ts +++ b/src/types/CanvasDatasetMutation.ts @@ -43,7 +43,7 @@ export type CanvasDatasetMutation = { /** * Patch to apply to the element. * - * Only set for "update" operations. + * Only set for "patch" operation. */ - changes?: PartialBaseNodeData | Partial + patch?: PartialBaseNodeData | Partial } From abe30f81b677e96eaf76c043f44c0b90f0c26579 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 15:54:02 +0100 Subject: [PATCH 107/148] Use state and auto-refresh UI when state changes --- src/components/editor/CanvasContainer.tsx | 15 ++++++++++++++- src/components/nodes/BaseNode.tsx | 7 +++---- src/types/BaseNodeProps.ts | 3 +-- src/types/CanvasDatasetMutation.ts | 2 +- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index 1bc9e7e..4c7c874 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -103,6 +103,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n 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, @@ -113,6 +114,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n cursorXY, isStreaming, canvasDocRef, + mutationsCounter, }); const nodes = canvasDataset?.nodes; const edges = canvasDataset?.edges; @@ -148,6 +150,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n elementType, operationType, patch, + status, } = mutation; if (elementType === 'node') { @@ -186,8 +189,9 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n * Add a new patch to apply to the existing queue. * * @param patch + * @param stateUpdateDelay (ms) */ - const addCanvasDatasetPatch: AddCanvasDatasetMutation = (patch) => { + const addCanvasDatasetPatch: AddCanvasDatasetMutation = (patch, stateUpdateDelay = 0) => { mutations.push({ status: 'waiting', id: uuid(), @@ -196,6 +200,15 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n operationType: patch.operationType, patch: patch.patch, }); + + // Updating the mutations counter will re-render the component + if (stateUpdateDelay) { + setTimeout(() => { + setMutationsCounter(mutationsCounter + 1); + }, stateUpdateDelay); + } else { + setMutationsCounter(mutationsCounter + 1); + } }; /** diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index f543948..1f05f7f 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -187,11 +187,10 @@ const BaseNode: BaseNodeComponent = (props) => { * Only updates the provided properties, doesn't update other properties. * Also merges the 'data' object, by keeping existing data and only overwriting those that are specified. * - * XXX This function is being debounced by default (when used by children components) to avoid sending a burst of updates to the database. - * * @param patch + * @param stateUpdateDelay (ms) */ - const patchCurrentNode: PatchCurrentNode = (patch: PartialBaseNodeData): void => { + const patchCurrentNode: PatchCurrentNode = (patch: PartialBaseNodeData, stateUpdateDelay = 0): void => { const mutation: NewCanvasDatasetMutation = { operationType: 'patch', elementId: node?.id, @@ -200,7 +199,7 @@ const BaseNode: BaseNodeComponent = (props) => { }; console.log('Adding patch to the queue', 'patch:', patch, 'mutation:', mutation); - addCanvasDatasetPatch(mutation); + addCanvasDatasetPatch(mutation, stateUpdateDelay); }; /** diff --git a/src/types/BaseNodeProps.ts b/src/types/BaseNodeProps.ts index 3234c88..82edf60 100644 --- a/src/types/BaseNodeProps.ts +++ b/src/types/BaseNodeProps.ts @@ -3,8 +3,7 @@ import BaseNodeData from './BaseNodeData'; import { AddCanvasDatasetMutation } from './CanvasDatasetMutation'; import PartialBaseNodeData from './PartialBaseNodeData'; -export type PatchCurrentNode = Partial> = (patch: PartialBaseNodeData) => void; -export type PatchCurrentNodeConcurrently = Partial> = (patch: PartialBaseNodeData) => void; +export type PatchCurrentNode = Partial> = (patch: PartialBaseNodeData, stateUpdateDelay?: number) => void; /** * Props received by any *Node component (InformationNode, etc.). diff --git a/src/types/CanvasDatasetMutation.ts b/src/types/CanvasDatasetMutation.ts index 933b16d..a25dc2b 100644 --- a/src/types/CanvasDatasetMutation.ts +++ b/src/types/CanvasDatasetMutation.ts @@ -2,7 +2,7 @@ import BaseEdgeData from './BaseEdgeData'; import PartialBaseNodeData from './PartialBaseNodeData'; export type NewCanvasDatasetMutation = Omit -export type AddCanvasDatasetMutation = (mutation: NewCanvasDatasetMutation) => void; +export type AddCanvasDatasetMutation = (mutation: NewCanvasDatasetMutation, stateUpdateDelay?: number) => void; export type CanvasDatasetMutation = { /** From b3da286395c7f6481cd425ab6ff31cef97bef20d Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 16:51:03 +0100 Subject: [PATCH 108/148] Refactor, move mutations handling to a dedicated file + doc --- src/components/editor/CanvasContainer.tsx | 82 ++----------- src/types/CanvasDatasetMutation.ts | 4 +- src/utils/canvasDatasetMutationsQueue.ts | 134 ++++++++++++++++++++++ 3 files changed, 146 insertions(+), 74 deletions(-) create mode 100644 src/utils/canvasDatasetMutationsQueue.ts diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index 4c7c874..df13970 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -2,8 +2,6 @@ import { Button } from '@chakra-ui/react'; import { css } from '@emotion/react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { isBrowser } from '@unly/utils'; -import cloneDeep from 'lodash.clonedeep'; -import merge from 'lodash.merge'; import React, { MutableRefObject, useEffect, @@ -29,14 +27,14 @@ import { canvasDatasetSelector } from '../../states/canvasDatasetSelector'; import { selectedEdgesSelector } from '../../states/selectedEdgesState'; import { selectedNodesSelector } from '../../states/selectedNodesState'; import { UserSession } from '../../types/auth/UserSession'; -import BaseEdgeData from '../../types/BaseEdgeData'; import BaseNodeData from '../../types/BaseNodeData'; import { CanvasDataset } from '../../types/CanvasDataset'; -import { - AddCanvasDatasetMutation, - CanvasDatasetMutation, -} from '../../types/CanvasDatasetMutation'; +import { AddCanvasDatasetMutation } from '../../types/CanvasDatasetMutation'; import { TypeOfRef } from '../../types/faunadb/TypeOfRef'; +import { + applyPendingMutations, + mutationsQueue, +} from '../../utils/canvasDatasetMutationsQueue'; import { onInit, onUpdate, @@ -61,8 +59,6 @@ type Props = { canvasRef: MutableRefObject; } -const mutations: CanvasDatasetMutation[] = []; - /** * Canvas container. * @@ -121,79 +117,21 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n const previousCanvasDataset: CanvasDataset | undefined = usePreviousValue(canvasDataset); /** - * Apply all patches that haven't been applied yet. + * Applies all patches that haven't been applied yet. */ useEffect(() => { - // Only consider mutations that are waiting - const mutationsToApply = mutations.filter((mutation: CanvasDatasetMutation) => mutation.status === 'waiting'); - console.log(`patchesToApply (${mutationsToApply?.length})`, mutationsToApply); - - if (mutationsToApply?.length > 0) { - const newNodes: BaseNodeData[] = cloneDeep(nodes); - const newEdges: BaseEdgeData[] = cloneDeep(edges); - - // Mark all patches as being processed - mutationsToApply.map((mutation: CanvasDatasetMutation) => { - const { id } = mutation; - const patchIndex: number = mutations.findIndex((mutation: CanvasDatasetMutation) => mutation.id === id); - - if (patchIndex >= 0) { - mutations[patchIndex].status = 'processing'; - } - }); - console.log(`patchesToApply (processing (${mutationsToApply?.length}))`, mutationsToApply); - - // Processing all waiting patches into one consolidated update - mutationsToApply.map((mutation: CanvasDatasetMutation) => { - const { - elementId, - elementType, - operationType, - patch, - status, - } = mutation; - - if (elementType === 'node') { - if (operationType === 'patch') { - const nodeToUpdateIndex: number = newNodes.findIndex((node: BaseNodeData) => node.id === elementId); - const existingNode: BaseNodeData | undefined = newNodes.find((node: BaseNodeData) => node?.id === elementId); - const patchedNode: BaseNodeData = {} as BaseNodeData; - - if (typeof existingNode !== 'undefined') { - merge(patchedNode, existingNode, patch); - console.log('Applying patch:', patch, 'to node:', existingNode, 'result:', patchedNode); - newNodes[nodeToUpdateIndex] = patchedNode; - } else { - console.log(`Couldn't find node to patch with id "${nodeToUpdateIndex}".`); - } - } else { - console.error(`Not implemented ${operationType}`); - } - } else { - console.error(`Not implemented ${elementType}`); - } - }); - - console.log('Saving new dataset (batch)', { - nodes: newNodes, - edges: newEdges, - }); - setCanvasDataset({ - nodes: newNodes, - edges: newEdges, - }); - } + applyPendingMutations({ nodes, edges, mutationsCounter, setCanvasDataset }); }); /** - * Add a new patch to apply to the existing queue. + * Adds a new patch to apply to the existing queue. * * @param patch * @param stateUpdateDelay (ms) */ const addCanvasDatasetPatch: AddCanvasDatasetMutation = (patch, stateUpdateDelay = 0) => { - mutations.push({ - status: 'waiting', + mutationsQueue.push({ + status: 'pending', id: uuid(), elementId: patch.elementId, elementType: patch.elementType, diff --git a/src/types/CanvasDatasetMutation.ts b/src/types/CanvasDatasetMutation.ts index a25dc2b..70b04fd 100644 --- a/src/types/CanvasDatasetMutation.ts +++ b/src/types/CanvasDatasetMutation.ts @@ -17,9 +17,9 @@ export type CanvasDatasetMutation = { * * - applied: Has been applied. * - processing: Is being processed. - * - waiting: Has not been processed yet, awaiting for the next batch of mutations. + * - pending: Has not been processed yet, awaiting for the next batch of mutations. */ - status: 'applied' | 'processing' | 'waiting'; + status: 'applied' | 'processing' | 'pending'; /** * The ID of the node/edge to mutate. diff --git a/src/utils/canvasDatasetMutationsQueue.ts b/src/utils/canvasDatasetMutationsQueue.ts new file mode 100644 index 0000000..aa0a4d1 --- /dev/null +++ b/src/utils/canvasDatasetMutationsQueue.ts @@ -0,0 +1,134 @@ +import cloneDeep from 'lodash.clonedeep'; +import merge from 'lodash.merge'; +import remove from 'lodash.remove'; +import { SetterOrUpdater } from 'recoil'; +import BaseEdgeData from '../types/BaseEdgeData'; +import BaseNodeData from '../types/BaseNodeData'; +import { CanvasDataset } from '../types/CanvasDataset'; +import { CanvasDatasetMutation } from '../types/CanvasDatasetMutation'; + +export type ApplyPendingMutationsArgs = { + nodes: BaseNodeData[]; + edges: BaseEdgeData[]; + mutationsCounter: number; + setCanvasDataset: SetterOrUpdater; +} +export type ApplyPendingMutations = ( + { + nodes, + edges, + mutationsCounter, + setCanvasDataset, + }: ApplyPendingMutationsArgs, +) => void; + +/** + * Global object initialized once per page. + * + * Contains the queue of mutations that have been done, or are to be done. + */ +export const mutationsQueue: CanvasDatasetMutation[] = []; + +/** + * Applies all pending mutations. + * + * Loops over all mutations, only consider those in "pending" state. + * Consolidate all mutations as one update by merging (in order) all mutations together. + * Then, update the canvas dataset (which will refresh the UI). + * + * @param nodes + * @param edges + * @param mutationsCounter + * @param setCanvasDataset + */ +export const applyPendingMutations: ApplyPendingMutations = ({ nodes, edges, mutationsCounter, setCanvasDataset }) => { + // Only consider mutations that are pending + const mutationsToApply = mutationsQueue.filter((mutation: CanvasDatasetMutation) => mutation.status === 'pending'); + console.log(`patchesToApply (${mutationsToApply?.length})`, mutationsToApply, 'queue:', mutationsQueue); + + if (mutationsToApply?.length > 0) { + const newNodes: BaseNodeData[] = cloneDeep(nodes); + const newEdges: BaseEdgeData[] = cloneDeep(edges); + + // Mark all patches as being processed + mutationsToApply.map((mutation: CanvasDatasetMutation) => { + const { id } = mutation; + const patchIndex: number = mutationsQueue.findIndex((mutation: CanvasDatasetMutation) => mutation.id === id); + + if (patchIndex >= 0) { + mutationsQueue[patchIndex].status = 'processing'; + } + }); + console.log(`patchesToApply (processing (${mutationsToApply?.length}))`, mutationsToApply); + + // Processing all pending patches into one consolidated update + mutationsToApply.map((mutation: CanvasDatasetMutation) => { + const { + elementId, + elementType, + operationType, + patch, + status, + } = mutation; + + if (status === 'processing') { + if (elementType === 'node') { + if (operationType === 'patch') { + const nodeToUpdateIndex: number = newNodes.findIndex((node: BaseNodeData) => node.id === elementId); + const existingNode: BaseNodeData | undefined = newNodes.find((node: BaseNodeData) => node?.id === elementId); + const patchedNode: BaseNodeData = {} as BaseNodeData; + + if (typeof existingNode !== 'undefined') { + merge(patchedNode, existingNode, patch); + console.log(`Applying patch N°${mutationsCounter}:`, patch, 'to node:', existingNode, 'result:', patchedNode); + newNodes[nodeToUpdateIndex] = patchedNode; + } else { + console.log(`Couldn't find node to patch with id "${nodeToUpdateIndex}".`); + } + } else { + console.error(`Not implemented ${operationType}`); + } + } else { + console.error(`Not implemented ${elementType}`); + } + } else { + console.log('The mutation must not be processed. (status !== "processing")', mutation); + } + }); + + console.log('Saving new dataset (batch)', { + nodes: newNodes, + edges: newEdges, + }); + setCanvasDataset({ + nodes: newNodes, + edges: newEdges, + }); + + // Mark all processed patches as having been applied + mutationsToApply.map((mutation: CanvasDatasetMutation) => { + const { id } = mutation; + const patchIndex: number = mutationsQueue.findIndex((mutation: CanvasDatasetMutation) => mutation.id === id); + + if (patchIndex >= 0) { + mutationsQueue[patchIndex].status = 'applied'; + } + }); + + // Cleanup mutations that have been applied to avoid memory leak for long-running sessions + setTimeout(() => { + cleanupAppliedPatches(); + }, 10000); + } +}; + +/** + * Remove all mutations that have been applied. + */ +export const cleanupAppliedPatches = () => { + const removedMutations = remove(mutationsQueue, (mutation: CanvasDatasetMutation) => { + return mutation.status === 'applied'; + }); + + console.debug('Mutations have been purged', removedMutations, 'queue:', mutationsQueue); +}; From d247de7c52a0dd913367e3c2b6f106112d524b7c Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 16:59:58 +0100 Subject: [PATCH 109/148] Fix calculation of dynamic height for QuestionNode --- src/components/nodes/QuestionNode.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/nodes/QuestionNode.tsx b/src/components/nodes/QuestionNode.tsx index 669267a..ccbeab4 100644 --- a/src/components/nodes/QuestionNode.tsx +++ b/src/components/nodes/QuestionNode.tsx @@ -31,7 +31,7 @@ type Props = {} & BaseNodeProps; const nodeType: NodeType = 'question'; const baseWidth = 250; -const baseHeight = 300; +const baseHeight = 310; /** * Question node. @@ -97,7 +97,10 @@ const QuestionNode: BaseNodeComponent = (props) => { questionTextareaHeight: additionalHeight, } as Partial, }; - const newHeight = calculateNodeHeight(patchedNodeAdditionalData.dynHeights, displayChoiceInputs); + const newHeight = calculateNodeHeight({ + ...node?.data?.dynHeights, + ...patchedNodeAdditionalData.dynHeights + }, displayChoiceInputs); console.log('onTextHeightChange ', node?.data?.dynHeights?.questionTextareaHeight, newHeight, node?.data?.dynHeights?.questionTextareaHeight !== newHeight); if (node?.data?.dynHeights?.questionTextareaHeight !== newHeight) { @@ -155,7 +158,11 @@ const QuestionNode: BaseNodeComponent = (props) => { const patchedNodeAdditionalData: Partial = { questionChoiceType: selectedChoiceValue, }; - const newHeight = calculateNodeHeight(patchedNodeAdditionalData.dynHeights, willDisplayChoiceInputs); + const newHeight = calculateNodeHeight({ + ...node?.data?.dynHeights, + ...patchedNodeAdditionalData.dynHeights, + }, willDisplayChoiceInputs); + console.log('selectedChoiceValue newHeight', newHeight); // Updates the value in the Recoil store patchCurrentNodeImmediately({ From 8265ba4ee05d8cdf63a509946f9b0fe2b22b46b6 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 18:15:11 +0100 Subject: [PATCH 110/148] Add more FQL config (not 100% working) --- fql/setup.js | 45 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/fql/setup.js b/fql/setup.js index dd5da7a..8069b24 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -83,16 +83,49 @@ CreateRole({ actions: { // Guests should only be allowed to read the Canvas of id "1" read: Query( - Lambda('ref', Equals( - '1', - Select(['id'], Get(Var('ref'))), - )), + Lambda('ref', + Equals( + '1', + Select(['id'], Get(Var('ref')), + ), + )), ), // Guests should only be allowed to update the Canvas of id "1" (but I don't know how to write that) - write: true, + write: Lambda( + ['oldData', 'newData'], + And( + Equals( + '1', + Select(['ref', 'id'], Get(Var('newData'))), + ), + Equals( + Select(['data', 'owner'], Var('oldData')), + Select(['data', 'owner'], Var('newData')), + ), + ), + ), // Guests should only be allowed to create the Canvas of id "1", but this requires admin permissions and will fail // See https://fauna-community.slack.com/archives/CAKNYCHCM/p1615413941454700 - create: true, + create: Lambda('values', + Equals( + '1', + Select(['ref', 'id'], Get(Var('values'))), + ), + ) + , + history_write: Lambda( + ['ref', 'ts', 'action', 'data'], + And( + Equals( + '1', + Select(['id'], Get(Var('ref'))), + ), + Equals( + Select(['data', 'owner'], Var('data')), + Select(['data', 'owner'], Get(Var('ref'))), + ), + ), + ), }, }, ], From 574a8c933ab487375a3b0b14ca9e87c50b8bfd05 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 18:17:13 +0100 Subject: [PATCH 111/148] Renaming (misc) --- src/components/editor/CanvasContainer.tsx | 6 +++--- src/components/nodes/BaseNode.tsx | 6 +++--- src/components/nodes/NodeRouter.tsx | 6 +++--- src/types/BaseNodeProps.ts | 2 +- src/types/nodes/SpecializedNodeProps.ts | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index df13970..905dc2e 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -117,7 +117,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n const previousCanvasDataset: CanvasDataset | undefined = usePreviousValue(canvasDataset); /** - * Applies all patches that haven't been applied yet. + * Applies all mutations that haven't been applied yet. */ useEffect(() => { applyPendingMutations({ nodes, edges, mutationsCounter, setCanvasDataset }); @@ -129,7 +129,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n * @param patch * @param stateUpdateDelay (ms) */ - const addCanvasDatasetPatch: AddCanvasDatasetMutation = (patch, stateUpdateDelay = 0) => { + const addCanvasDatasetMutation: AddCanvasDatasetMutation = (patch, stateUpdateDelay = 0) => { mutationsQueue.push({ status: 'pending', id: uuid(), @@ -303,7 +303,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n return ( ); }; diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index 1f05f7f..6f1e278 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -85,7 +85,7 @@ const BaseNode: BaseNodeComponent = (props) => { baseHeight, patchCurrentNodeWait, patchCurrentNodeOptions, - addCanvasDatasetPatch, + addCanvasDatasetMutation, ...nodeProps // All props that are left will be forwarded to the Node component } = props; @@ -199,7 +199,7 @@ const BaseNode: BaseNodeComponent = (props) => { }; console.log('Adding patch to the queue', 'patch:', patch, 'mutation:', mutation); - addCanvasDatasetPatch(mutation, stateUpdateDelay); + addCanvasDatasetMutation(mutation, stateUpdateDelay); }; /** @@ -370,7 +370,7 @@ const BaseNode: BaseNodeComponent = (props) => { lastCreated, patchCurrentNode, patchCurrentNodeImmediately: patchCurrentNode, - addCanvasDatasetPatch, + addCanvasDatasetMutation, }; return ( diff --git a/src/components/nodes/NodeRouter.tsx b/src/components/nodes/NodeRouter.tsx index 8b6b288..0f32e5a 100644 --- a/src/components/nodes/NodeRouter.tsx +++ b/src/components/nodes/NodeRouter.tsx @@ -9,7 +9,7 @@ import { findNodeComponentByType } from '../../utils/nodes'; type Props = { nodeProps: NodeProps; - addCanvasDatasetPatch: AddCanvasDatasetMutation; + addCanvasDatasetMutation: AddCanvasDatasetMutation; } /** @@ -20,7 +20,7 @@ type Props = { const NodeRouter: React.FunctionComponent = (props) => { const { nodeProps, - addCanvasDatasetPatch, + addCanvasDatasetMutation, } = props; const nodeType = nodeProps?.properties?.data?.type; const [nodes, setNodes] = useRecoilState(nodesSelector); @@ -50,7 +50,7 @@ const NodeRouter: React.FunctionComponent = (props) => { ); }; diff --git a/src/types/BaseNodeProps.ts b/src/types/BaseNodeProps.ts index 82edf60..c5dd0ea 100644 --- a/src/types/BaseNodeProps.ts +++ b/src/types/BaseNodeProps.ts @@ -17,7 +17,7 @@ export type BaseNodeProps = { /** * TODO */ - addCanvasDatasetPatch: AddCanvasDatasetMutation; + addCanvasDatasetMutation: AddCanvasDatasetMutation; } & Partial; export default BaseNodeProps; diff --git a/src/types/nodes/SpecializedNodeProps.ts b/src/types/nodes/SpecializedNodeProps.ts index a2a7f6c..691a253 100644 --- a/src/types/nodes/SpecializedNodeProps.ts +++ b/src/types/nodes/SpecializedNodeProps.ts @@ -50,6 +50,6 @@ export type SpecializedNodeProps = /** * TODO */ - addCanvasDatasetPatch: AddCanvasDatasetMutation; + addCanvasDatasetMutation: AddCanvasDatasetMutation; } From 47f63c4d59c8056699940aedd4ae0951371d38c1 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 18:46:43 +0100 Subject: [PATCH 112/148] Add useDebouncedEffect to avoid starting too many streams at once when things go south --- src/components/FaunaDBCanvasStream.tsx | 23 ++++++++++++++------- src/hooks/useDebouncedEffect.tsx | 28 ++++++++++++++++++++++++++ src/utils/canvasStream.ts | 2 ++ 3 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 src/hooks/useDebouncedEffect.tsx diff --git a/src/components/FaunaDBCanvasStream.tsx b/src/components/FaunaDBCanvasStream.tsx index 49a92f7..ea6e20d 100644 --- a/src/components/FaunaDBCanvasStream.tsx +++ b/src/components/FaunaDBCanvasStream.tsx @@ -4,9 +4,9 @@ import { Subscription } from 'faunadb/src/types/Stream'; import React, { Dispatch, SetStateAction, - useEffect, useState, } from 'react'; +import { useDebouncedEffect } from '../hooks/useDebouncedEffect'; import { useUserSession } from '../hooks/useUserSession'; import { OnInit, @@ -45,7 +45,14 @@ const FaunaDBCanvasStream: React.FunctionComponent = (props) => { return null; } - const onStart: OnStart = (stream: Subscription, canvasRef: TypeOfRef, at: number) => { + /** + * Triggered when the stream has started. + * + * @param stream + * @param canvasRef + * @param at + */ + const onStreamStarted: OnStart = (stream: Subscription, canvasRef: TypeOfRef, at: number) => { setStream(stream); setCanvasRef(canvasRef); setCanvasDocRef(canvasRef); @@ -53,27 +60,29 @@ const FaunaDBCanvasStream: React.FunctionComponent = (props) => { }; /** - * Handles stream subscription + * 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. */ - useEffect(() => { + 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, onStart, onInit, onUpdate); + initStream(user, onStreamStarted, onInit, onUpdate); } 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, onStart, onInit, onUpdate); + initStream(user, onStreamStarted, onInit, onUpdate); } - }, [user?.id]); + }, 1000, [user?.id]); // Display meta information about the current document, helps debugging/understanding which document is being updated return ( diff --git a/src/hooks/useDebouncedEffect.tsx b/src/hooks/useDebouncedEffect.tsx new file mode 100644 index 0000000..008fc20 --- /dev/null +++ b/src/hooks/useDebouncedEffect.tsx @@ -0,0 +1,28 @@ +import { + DependencyList, + useCallback, + useEffect, +} from 'react'; + +/** + * Debounces a React.useEffect function. + * + * @param effect + * @param delay + * @param deps + * + * @see https://stackoverflow.com/a/61127960/2391795 + */ +export const useDebouncedEffect = (effect: () => any, delay: number, deps: DependencyList) => { + const callback = useCallback(effect, deps); + + useEffect(() => { + const handler = setTimeout(() => { + callback(); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [callback, delay]); +} diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 8daad66..3ad004f 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -48,6 +48,8 @@ export const getUserClient = (user: Partial): Client => { * @param onStart * @param onInit * @param onUpdate + * + * @see https://docs.fauna.com/fauna/current/drivers/streaming.html#events */ export const initStream = async (user: Partial, onStart: OnStart, onInit: OnInit, onUpdate: OnUpdate) => { console.log('Init stream for user', user); From 5ff226cae5f382b9226d8bfb4a84c9d1bd1dafc3 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 19:31:36 +0100 Subject: [PATCH 113/148] Improve streaming error handling --- src/components/FaunaDBCanvasStream.tsx | 57 ++++++++++++++++++++++---- src/types/faunadb/CanvasStream.ts | 8 ++-- src/types/faunadb/FaunaError.ts | 5 +++ src/utils/canvasStream.ts | 19 +++++---- 4 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 src/types/faunadb/FaunaError.ts diff --git a/src/components/FaunaDBCanvasStream.tsx b/src/components/FaunaDBCanvasStream.tsx index ea6e20d..7b2266c 100644 --- a/src/components/FaunaDBCanvasStream.tsx +++ b/src/components/FaunaDBCanvasStream.tsx @@ -1,6 +1,8 @@ +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, @@ -9,16 +11,18 @@ import React, { import { useDebouncedEffect } from '../hooks/useDebouncedEffect'; import { useUserSession } from '../hooks/useUserSession'; import { - OnInit, - OnStart, - OnUpdate, + 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: OnInit; - onUpdate: OnUpdate; + onInit: OnStreamInit; + onUpdate: OnStreamedDocumentUpdate; setCanvasDocRef: Dispatch>; } @@ -39,7 +43,14 @@ const FaunaDBCanvasStream: React.FunctionComponent = (props) => { 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; @@ -52,13 +63,43 @@ const FaunaDBCanvasStream: React.FunctionComponent = (props) => { * @param canvasRef * @param at */ - const onStreamStarted: OnStart = (stream: Subscription, canvasRef: TypeOfRef, at: number) => { + 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. * @@ -73,14 +114,14 @@ const FaunaDBCanvasStream: React.FunctionComponent = (props) => { // 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); + 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); + initStream(user, onStreamStarted, onInit, onUpdate, onStreamError); } }, 1000, [user?.id]); diff --git a/src/types/faunadb/CanvasStream.ts b/src/types/faunadb/CanvasStream.ts index baae430..89814e7 100644 --- a/src/types/faunadb/CanvasStream.ts +++ b/src/types/faunadb/CanvasStream.ts @@ -1,7 +1,9 @@ import { Subscription } from 'faunadb/src/types/Stream'; import { CanvasDataset } from '../CanvasDataset'; +import { FaunaError } from './FaunaError'; import { TypeOfRef } from './TypeOfRef'; -export type OnStart = (stream: Subscription, canvasRef: TypeOfRef, at: number) => void; -export type OnInit = (canvasDataset: CanvasDataset) => void; -export type OnUpdate = (canvasDatasetRemotelyUpdated: CanvasDataset) => void; +export type OnStreamStart = (stream: Subscription, canvasRef: TypeOfRef, at: number) => void; +export type OnStreamInit = (canvasDataset: CanvasDataset) => void; +export type OnStreamedDocumentUpdate = (canvasDatasetRemotelyUpdated: CanvasDataset) => void; +export type OnStreamError = (error: FaunaError, restartStream: () => void) => void; diff --git a/src/types/faunadb/FaunaError.ts b/src/types/faunadb/FaunaError.ts new file mode 100644 index 0000000..b6c66b5 --- /dev/null +++ b/src/types/faunadb/FaunaError.ts @@ -0,0 +1,5 @@ +export interface FaunaError extends Error { + name: string; + message: string; + description: string; +} diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 3ad004f..4d6fbcc 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -25,9 +25,10 @@ import { import { CanvasByOwnerIndex } from '../types/faunadb/CanvasByOwnerIndex'; import { CanvasDatasetResult } from '../types/faunadb/CanvasDatasetResult'; import { - OnInit, - OnStart, - OnUpdate, + OnStreamedDocumentUpdate, + OnStreamError, + OnStreamInit, + OnStreamStart, } from '../types/faunadb/CanvasStream'; import { FaunadbStreamVersionEvent } from '../types/faunadb/FaunadbStreamVersionEvent'; import { TypeOfRef } from '../types/faunadb/TypeOfRef'; @@ -49,9 +50,10 @@ export const getUserClient = (user: Partial): Client => { * @param onInit * @param onUpdate * + * @param onError * @see https://docs.fauna.com/fauna/current/drivers/streaming.html#events */ -export const initStream = async (user: Partial, onStart: OnStart, onInit: OnInit, onUpdate: OnUpdate) => { +export const initStream = async (user: Partial, onStart: OnStreamStart, onInit: OnStreamInit, onUpdate: OnStreamedDocumentUpdate, onError: OnStreamError) => { console.log('Init stream for user', user); const client: Client = getUserClient(user); const canvasRef: Expr | undefined = await findUserCanvasRef(user); @@ -108,13 +110,14 @@ export const initStream = async (user: Partial, onStart: OnStart, o onInit(result?.data); } else { stream.close(); - setTimeout(_startStream, 1000); + onError(error, _startStream); } }) + // Not tested .on('history_rewrite', (error: any) => { console.log('Error:', error); stream.close(); - setTimeout(_startStream, 1000); + onError(error, _startStream); }) .start(); }; @@ -320,7 +323,7 @@ export const updateUserCanvas = async (canvasRef: TypeOfRef | undefined, user: P * @see https://fauna.com/blog/live-ui-updates-with-faunas-real-time-document-streaming#defining-the-stream * @see https://docs.fauna.com/fauna/current/drivers/javascript.html */ -export const onInit: OnInit = (canvasDataset: CanvasDataset) => { +export const onInit: OnStreamInit = (canvasDataset: CanvasDataset) => { // Starts the stream between the browser and the FaunaDB using the default canvas document console.log('onInit canvasDataset', canvasDataset); setRecoilExternalState(canvasDatasetSelector, canvasDataset); @@ -341,6 +344,6 @@ export const onInit: OnInit = (canvasDataset: CanvasDataset) => { * @see https://fauna.com/blog/live-ui-updates-with-faunas-real-time-document-streaming#defining-the-stream * @see https://docs.fauna.com/fauna/current/drivers/javascript.html */ -export const onUpdate: OnUpdate = (canvasDatasetRemotelyUpdated: CanvasDataset) => { +export const onUpdate: OnStreamedDocumentUpdate = (canvasDatasetRemotelyUpdated: CanvasDataset) => { setRecoilExternalState(canvasDatasetSelector, canvasDatasetRemotelyUpdated); }; From 77095056d365ed7ae93acb316f860171bd030130 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 19:34:36 +0100 Subject: [PATCH 114/148] Fix Public:read role --- fql/setup.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fql/setup.js b/fql/setup.js index 8069b24..9389dbd 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -86,9 +86,10 @@ CreateRole({ Lambda('ref', Equals( '1', - Select(['id'], Get(Var('ref')), + Select(['id'], Var('ref'), ), - )), + ) + ), ), // Guests should only be allowed to update the Canvas of id "1" (but I don't know how to write that) write: Lambda( From 14513e864804fae5283f5534ef36dbded2362f29 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 19:52:54 +0100 Subject: [PATCH 115/148] Fix Public role configuration --- fql/setup.js | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/fql/setup.js b/fql/setup.js index 9389dbd..14be18e 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -88,21 +88,15 @@ CreateRole({ '1', Select(['id'], Var('ref'), ), - ) + ), ), ), // Guests should only be allowed to update the Canvas of id "1" (but I don't know how to write that) write: Lambda( - ['oldData', 'newData'], - And( - Equals( - '1', - Select(['ref', 'id'], Get(Var('newData'))), - ), - Equals( - Select(['data', 'owner'], Var('oldData')), - Select(['data', 'owner'], Var('newData')), - ), + ['oldData', 'newData', 'ref'], + Equals( + '1', + Select(['id'], Var('ref')), ), ), // Guests should only be allowed to create the Canvas of id "1", but this requires admin permissions and will fail @@ -110,21 +104,14 @@ CreateRole({ create: Lambda('values', Equals( '1', - Select(['ref', 'id'], Get(Var('values'))), + Select(['ref', 'id'], Var('values')), ), - ) - , + ), history_write: Lambda( ['ref', 'ts', 'action', 'data'], - And( - Equals( - '1', - Select(['id'], Get(Var('ref'))), - ), - Equals( - Select(['data', 'owner'], Var('data')), - Select(['data', 'owner'], Get(Var('ref'))), - ), + Equals( + '1', + Select(['id'], Var('ref')), ), ), }, From 652af82ec6e915956384524c81edcc35b00e21b4 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 21:17:19 +0100 Subject: [PATCH 116/148] Fix Editor role create/write permissions + add doc --- fql/setup.js | 52 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/fql/setup.js b/fql/setup.js index 14be18e..cb29dc4 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -1,10 +1,10 @@ -// Step 1: Create a "Users" collection +// ---------------------- Step 1: Create a "Users" collection ---------------------- CreateCollection({ name: 'Users' }); -// Step 2: Create "Canvas" collection +// ---------------------- Step 2: Create "Canvas" collection ---------------------- CreateCollection({ name: 'Canvas' }); -// Step 3: Create indexes +// ---------------------- Step 3: Create indexes ---------------------- // Index to filter users by email // Necessary for authentication, to find the user document based on their email CreateIndex({ @@ -33,7 +33,13 @@ CreateIndex({ ], }); -// Step 4: Create roles +// ---------------------- 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). @@ -51,7 +57,7 @@ CreateRole({ { resource: Collection('Canvas'), actions: { - // Editors should be able to read (+ history) only Canvas documents that belongs to them. + // Editors should be able to read (+ history) of Canvas documents that belongs to them. read: Query( Lambda('ref', Equals( CurrentIdentity(), @@ -64,15 +70,36 @@ CreateRole({ Select(['data', 'owner'], Get(Var('ref'))), )), ), - // Editors should be able to edit only Canvas documents that belongs to them (but I don't know how to write that). - write: true, - // Editors should be able to create only Canvas documents that belongs to them (but I don't know how to write that). - create: true, + // Editors should be able to edit only Canvas documents that belongs to them + write: 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: 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 @@ -91,7 +118,7 @@ CreateRole({ ), ), ), - // Guests should only be allowed to update the Canvas of id "1" (but I don't know how to write that) + // Guests should only be allowed to update the Canvas of id "1" write: Lambda( ['oldData', 'newData', 'ref'], Equals( @@ -99,14 +126,15 @@ CreateRole({ Select(['id'], Var('ref')), ), ), - // Guests should only be allowed to create the Canvas of id "1", but this requires admin permissions and will fail - // See https://fauna-community.slack.com/archives/CAKNYCHCM/p1615413941454700 + // Guests should only be allowed to create the Canvas of id "1" create: 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: Lambda( ['ref', 'ts', 'action', 'data'], Equals( From 07bf499fd26a016ab9029b0b5737c34be9b5594c Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 22:06:45 +0100 Subject: [PATCH 117/148] Update documentation --- README.md | 135 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 87 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index bc546a2..af918df 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,72 +21,102 @@ 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. +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. ## 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 +--- + +# Advanced -ELKjs (and ELK) are used to draw the graph (nodes, edges). +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**. @@ -85,7 +124,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) @@ -115,36 +154,36 @@ Here is a list of online resources and open-source repositories that have been t - 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 -**Real-world apps (RWA):** +**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 -**FQL:** +**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 -**GQL:** +**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 -**DevOps:** +**FaunaDB DevOps:** - https://github.com/fauna-brecht/fauna-schema-migrate -**Community resources:** +**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 @@ -156,13 +195,13 @@ Here is a list of online resources and open-source repositories that have been t The way the current real-time feature is implemented is not too bad, but not great either. -It works by synching the whole dataset whether the remote `document` (on FaunaDB) is updated, which in turn updates all subscribed clients. -While this works, work from one client can be overwritten by another when they happen at the same time. +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. +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. From f4d54bc36996457a993a2fc1bb431295de291ead Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 22:14:11 +0100 Subject: [PATCH 118/148] Add roadmap --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index af918df..4386844 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,13 @@ While working on this project, I've reached several milestones with a different 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. + ## 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!). From 342fc484a5d912edcd2a0e93a369fb60eb7037d3 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 22:19:40 +0100 Subject: [PATCH 119/148] Add FaunaDB IaC --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 4386844..23fe18d 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,11 @@ 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. ## Getting started From 2b72b095fb6cf1ef747709fc4d71b4f6d1b4fabe Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 22:20:19 +0100 Subject: [PATCH 120/148] Contributing --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 23fe18d..e6e64a5 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ Here are the future variants I intend to work on: 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!). From 943cd4a0ee8b5176a7f62e1d2995e7fc6c80929e Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 22:25:15 +0100 Subject: [PATCH 121/148] Renaming "patch" > "changes" --- src/components/editor/CanvasContainer.tsx | 2 +- src/components/nodes/BaseNode.tsx | 2 +- src/types/CanvasDatasetMutation.ts | 4 ++-- src/utils/canvasDatasetMutationsQueue.ts | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index 905dc2e..bffbc20 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -136,7 +136,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n elementId: patch.elementId, elementType: patch.elementType, operationType: patch.operationType, - patch: patch.patch, + changes: patch.changes, }); // Updating the mutations counter will re-render the component diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index 6f1e278..e3c165a 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -195,7 +195,7 @@ const BaseNode: BaseNodeComponent = (props) => { operationType: 'patch', elementId: node?.id, elementType: 'node', - patch: patch, + changes: patch, }; console.log('Adding patch to the queue', 'patch:', patch, 'mutation:', mutation); diff --git a/src/types/CanvasDatasetMutation.ts b/src/types/CanvasDatasetMutation.ts index 70b04fd..ef84fcc 100644 --- a/src/types/CanvasDatasetMutation.ts +++ b/src/types/CanvasDatasetMutation.ts @@ -43,7 +43,7 @@ export type CanvasDatasetMutation = { /** * Patch to apply to the element. * - * Only set for "patch" operation. + * Only set for "add" and "patch" operation. */ - patch?: PartialBaseNodeData | Partial + changes?: PartialBaseNodeData | Partial } diff --git a/src/utils/canvasDatasetMutationsQueue.ts b/src/utils/canvasDatasetMutationsQueue.ts index aa0a4d1..865dcf1 100644 --- a/src/utils/canvasDatasetMutationsQueue.ts +++ b/src/utils/canvasDatasetMutationsQueue.ts @@ -67,7 +67,7 @@ export const applyPendingMutations: ApplyPendingMutations = ({ nodes, edges, mut elementId, elementType, operationType, - patch, + changes, status, } = mutation; @@ -79,8 +79,8 @@ export const applyPendingMutations: ApplyPendingMutations = ({ nodes, edges, mut const patchedNode: BaseNodeData = {} as BaseNodeData; if (typeof existingNode !== 'undefined') { - merge(patchedNode, existingNode, patch); - console.log(`Applying patch N°${mutationsCounter}:`, patch, 'to node:', existingNode, 'result:', patchedNode); + merge(patchedNode, existingNode, changes); + console.log(`Applying patch N°${mutationsCounter}:`, changes, 'to node:', existingNode, 'result:', patchedNode); newNodes[nodeToUpdateIndex] = patchedNode; } else { console.log(`Couldn't find node to patch with id "${nodeToUpdateIndex}".`); From 6a70b086722ac660e9a5bd573040a5c3e2384575 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 22:32:40 +0100 Subject: [PATCH 122/148] Improve debugging experience by cloning all object printed to the console to avoid confusion --- src/utils/canvasDatasetMutationsQueue.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/canvasDatasetMutationsQueue.ts b/src/utils/canvasDatasetMutationsQueue.ts index 865dcf1..84e2ef7 100644 --- a/src/utils/canvasDatasetMutationsQueue.ts +++ b/src/utils/canvasDatasetMutationsQueue.ts @@ -44,7 +44,7 @@ export const mutationsQueue: CanvasDatasetMutation[] = []; export const applyPendingMutations: ApplyPendingMutations = ({ nodes, edges, mutationsCounter, setCanvasDataset }) => { // Only consider mutations that are pending const mutationsToApply = mutationsQueue.filter((mutation: CanvasDatasetMutation) => mutation.status === 'pending'); - console.log(`patchesToApply (${mutationsToApply?.length})`, mutationsToApply, 'queue:', mutationsQueue); + console.log(`mutationsToApply (${mutationsToApply?.length})`, cloneDeep(mutationsToApply), 'queue:', cloneDeep(mutationsQueue)); if (mutationsToApply?.length > 0) { const newNodes: BaseNodeData[] = cloneDeep(nodes); @@ -59,7 +59,7 @@ export const applyPendingMutations: ApplyPendingMutations = ({ nodes, edges, mut mutationsQueue[patchIndex].status = 'processing'; } }); - console.log(`patchesToApply (processing (${mutationsToApply?.length}))`, mutationsToApply); + console.log(`mutationsToApply (processing (${mutationsToApply?.length}))`, cloneDeep(mutationsToApply)); // Processing all pending patches into one consolidated update mutationsToApply.map((mutation: CanvasDatasetMutation) => { From 4064fb046c94ac5d55818a433804601f006289c8 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 22:33:23 +0100 Subject: [PATCH 123/148] Implement "add" on queue --- src/components/nodes/BaseNode.tsx | 13 ++++++++----- src/utils/canvasDatasetMutationsQueue.ts | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index e3c165a..d4aea38 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -209,12 +209,15 @@ const BaseNode: BaseNodeComponent = (props) => { */ const onNodeClone = (event: React.MouseEvent) => { const clonedNode: BaseNodeData = cloneNode(node); - console.log('clonedNode', clonedNode, nodes); + const mutation: NewCanvasDatasetMutation = { + operationType: 'add', + elementId: node?.id, + elementType: 'node', + changes: clonedNode, + }; + console.log('Adding patch to the queue', 'node:', clonedNode, 'mutation:', mutation); - setNodes([ - ...nodes, - clonedNode, - ]); + addCanvasDatasetMutation(mutation); }; /** diff --git a/src/utils/canvasDatasetMutationsQueue.ts b/src/utils/canvasDatasetMutationsQueue.ts index 84e2ef7..da1c56f 100644 --- a/src/utils/canvasDatasetMutationsQueue.ts +++ b/src/utils/canvasDatasetMutationsQueue.ts @@ -85,6 +85,8 @@ export const applyPendingMutations: ApplyPendingMutations = ({ nodes, edges, mut } else { console.log(`Couldn't find node to patch with id "${nodeToUpdateIndex}".`); } + } else if(operationType === 'add') { + newNodes.push(changes as BaseNodeData); } else { console.error(`Not implemented ${operationType}`); } From c918f3c15658eff25848cca00b2160465d702468 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 22:35:35 +0100 Subject: [PATCH 124/148] Implement "delete" on queue --- src/utils/canvasDatasetMutationsQueue.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/canvasDatasetMutationsQueue.ts b/src/utils/canvasDatasetMutationsQueue.ts index da1c56f..693ef44 100644 --- a/src/utils/canvasDatasetMutationsQueue.ts +++ b/src/utils/canvasDatasetMutationsQueue.ts @@ -85,8 +85,10 @@ export const applyPendingMutations: ApplyPendingMutations = ({ nodes, edges, mut } else { console.log(`Couldn't find node to patch with id "${nodeToUpdateIndex}".`); } - } else if(operationType === 'add') { + } else if (operationType === 'add') { newNodes.push(changes as BaseNodeData); + } else if (operationType === 'delete') { + remove(newNodes, (node: BaseNodeData) => node?.id === elementId); } else { console.error(`Not implemented ${operationType}`); } From b0a5f606080844642e87107d819244f253e5c824 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 22:48:22 +0100 Subject: [PATCH 125/148] Implement "add/patch/delete" on queue (edge) --- src/components/edges/BaseEdge.tsx | 42 +++++++++++------------ src/components/editor/CanvasContainer.tsx | 1 + src/components/nodes/BaseNode.tsx | 6 ++-- src/types/BaseEdgeProps.ts | 5 ++- src/utils/canvasDatasetMutationsQueue.ts | 20 +++++++++++ 5 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/components/edges/BaseEdge.tsx b/src/components/edges/BaseEdge.tsx index 71f0ac1..6a91927 100644 --- a/src/components/edges/BaseEdge.tsx +++ b/src/components/edges/BaseEdge.tsx @@ -26,6 +26,7 @@ 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'; @@ -55,6 +56,7 @@ const BaseEdge: React.FunctionComponent = (props) => { sourcePort: sourcePortId, target: targetNodeId, targetPort: targetPortId, + addCanvasDatasetMutation, } = props; // console.log('props', props) @@ -135,7 +137,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); + addCanvasDatasetMutation(mutation); }; /** @@ -153,33 +162,24 @@ const BaseEdge: React.FunctionComponent = (props) => { }; /** - * Path the properties of the current node. + * Patches the properties of the current edge. * * Only updates the provided properties, doesn't update other properties. - * Also merges the 'data' object, by keeping existing data and only overwriting those that are specified. - * - * XXX Make sure to call this function once per function call, otherwise only the last patch call would be persisted correctly - * (multiple calls within the same function would be overridden by the last patch, - * because the "node" used as reference wouldn't be updated right away and would still use the same (outdated) reference) - * TLDR; Don't use "patchCurrentNode" multiple times in the same function, it won't work as expected + * Will use deep merge of properties. * * @param patch + * @param stateUpdateDelay */ - const patchCurrentEdge: PatchCurrentEdge = (patch: Partial): void => { - const edgeToUpdateIndex = edges.findIndex((edge: BaseEdgeData) => edge.id === id); - const existingEdge: BaseEdgeData = edges[edgeToUpdateIndex]; - const edgeToUpdate = { - ...existingEdge, - ...patch, - id: existingEdge.id, // Force keep same id to avoid edge cases + const patchCurrentEdge: PatchCurrentEdge = (patch: Partial, stateUpdateDelay = 0): void => { + const mutation: NewCanvasDatasetMutation = { + operationType: 'patch', + elementId: edge?.id, + elementType: 'edge', + changes: patch, }; - console.log('patchCurrentEdge before', existingEdge, 'after:', edgeToUpdate, 'using patch:', patch); - - const newEdges = cloneDeep(edges); - // @ts-ignore - newEdges[edgeToUpdateIndex] = edgeToUpdate; - setEdges(newEdges); + console.log('Adding edge patch to the queue', 'patch:', patch, 'mutation:', mutation); + addCanvasDatasetMutation(mutation, stateUpdateDelay); }; return ( diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index bffbc20..d65a25e 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -318,6 +318,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n return ( ); }; diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index d4aea38..1b64294 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -182,10 +182,10 @@ const BaseNode: BaseNodeComponent = (props) => { ]); /** - * Path the properties of the current node. + * Patches the properties of the current node. * * Only updates the provided properties, doesn't update other properties. - * Also merges the 'data' object, by keeping existing data and only overwriting those that are specified. + * Will use deep merge of properties. * * @param patch * @param stateUpdateDelay (ms) @@ -198,7 +198,7 @@ const BaseNode: BaseNodeComponent = (props) => { changes: patch, }; - console.log('Adding patch to the queue', 'patch:', patch, 'mutation:', mutation); + console.log('Adding node patch to the queue', 'patch:', patch, 'mutation:', mutation); addCanvasDatasetMutation(mutation, stateUpdateDelay); }; diff --git a/src/types/BaseEdgeProps.ts b/src/types/BaseEdgeProps.ts index 5255e26..14954c6 100644 --- a/src/types/BaseEdgeProps.ts +++ b/src/types/BaseEdgeProps.ts @@ -1,5 +1,6 @@ import { EdgeProps } from 'reaflow'; import BaseEdgeData from './BaseEdgeData'; +import { AddCanvasDatasetMutation } from './CanvasDatasetMutation'; export type PatchCurrentEdge = Partial> = (patch: Partial) => void; @@ -8,6 +9,8 @@ export type PatchCurrentEdge = Partial; +export type BaseEdgeProps = Partial & { + addCanvasDatasetMutation: AddCanvasDatasetMutation; +}; export default BaseEdgeProps; diff --git a/src/utils/canvasDatasetMutationsQueue.ts b/src/utils/canvasDatasetMutationsQueue.ts index 693ef44..70423d4 100644 --- a/src/utils/canvasDatasetMutationsQueue.ts +++ b/src/utils/canvasDatasetMutationsQueue.ts @@ -92,6 +92,26 @@ export const applyPendingMutations: ApplyPendingMutations = ({ nodes, edges, mut } else { console.error(`Not implemented ${operationType}`); } + } else if (elementType === 'edge') { + if (operationType === 'patch') { + const edgeToUpdateIndex: number = newEdges.findIndex((edge: BaseEdgeData) => edge.id === elementId); + const existingEdge: BaseEdgeData | undefined = newEdges.find((edge: BaseEdgeData) => edge?.id === elementId); + const patchedEdge: BaseEdgeData = {} as BaseEdgeData; + + if (typeof existingEdge !== 'undefined') { + merge(patchedEdge, existingEdge, changes); + console.log(`Applying patch N°${mutationsCounter}:`, changes, 'to edge:', existingEdge, 'result:', patchedEdge); + newEdges[edgeToUpdateIndex] = patchedEdge; + } else { + console.log(`Couldn't find edge to patch with id "${edgeToUpdateIndex}".`); + } + } else if (operationType === 'add') { + newEdges.push(changes as BaseEdgeData); + } else if (operationType === 'delete') { + remove(newEdges, (edges: BaseEdgeData) => edges?.id === elementId); + } else { + console.error(`Not implemented ${elementType}`); + } } else { console.error(`Not implemented ${elementType}`); } From c0bda6b0046388b087d9233ae9cff557250619e2 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 22:56:05 +0100 Subject: [PATCH 126/148] Replace more setEdges calls by queue --- src/components/nodes/BaseNode.tsx | 1 + src/components/ports/BasePort.tsx | 21 ++++++++++++--------- src/types/BasePortProps.ts | 10 +++++++++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index 1b64294..d7927c1 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -350,6 +350,7 @@ const BaseNode: BaseNodeComponent = (props) => { isNodeReachable: isReachable, }} PortChildComponent={BasePortChild} + addCanvasDatasetMutation={addCanvasDatasetMutation} /> )} > diff --git a/src/components/ports/BasePort.tsx b/src/components/ports/BasePort.tsx index bda7948..ea39ff5 100644 --- a/src/components/ports/BasePort.tsx +++ b/src/components/ports/BasePort.tsx @@ -31,6 +31,7 @@ import BasePortData from '../../types/BasePortData'; import BasePortProps from '../../types/BasePortProps'; 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'; @@ -45,11 +46,7 @@ import { getDefaultToPort, } from '../../utils/ports'; -type Props = { - fromNodeId: string; - additionalPortChildProps: AdditionalPortChildProps; - PortChildComponent: React.FunctionComponent; -} & BasePortProps; +type Props = BasePortProps; /** * Base port component. @@ -70,6 +67,7 @@ const BasePort: React.FunctionComponent = (props) => { PortChildComponent, onDragStart: onDragStartInternal, onDragEnd: onDragEndInternal, + addCanvasDatasetMutation, } = props; const [blockPickerMenu, setBlockPickerMenu] = useRecoilState(blockPickerMenuSelector); @@ -231,10 +229,15 @@ const BasePort: React.FunctionComponent = (props) => { const newEdge: BaseEdgeData = createEdge(fromNode, toNode, fromPort, toPort); console.log('Linking existing nodes through new edge', newEdge); - setEdges([ - ...edges, - newEdge, - ]); + const mutation: NewCanvasDatasetMutation = { + operationType: 'add', + elementId: newEdge?.id, + elementType: 'edge', + changes: newEdge, + }; + + console.log('Adding edge patch to the queue', 'mutation:', mutation); + addCanvasDatasetMutation(mutation); } else { console.error(`You cannot connect the link to that port.`); alert(`You cannot connect the link to that port.`); diff --git a/src/types/BasePortProps.ts b/src/types/BasePortProps.ts index 34e8c80..8f5d516 100644 --- a/src/types/BasePortProps.ts +++ b/src/types/BasePortProps.ts @@ -1,10 +1,18 @@ +import React from 'react'; import { PortProps } from 'reaflow'; +import BasePortChildProps, { AdditionalPortChildProps } from './BasePortChildProps'; +import { AddCanvasDatasetMutation } from './CanvasDatasetMutation'; /** * Props received by any port component (BasePort). * * Doesn't do anything particular at the moment, used in case we'd need to extend it later on. */ -export type BasePortProps = {} & Partial; +export type BasePortProps = Partial & { + fromNodeId: string; + additionalPortChildProps: AdditionalPortChildProps; + PortChildComponent: React.FunctionComponent; + addCanvasDatasetMutation: AddCanvasDatasetMutation; +}; export default BasePortProps; From 7f37e048f4a308b705457447bc9497609fcd3fbd Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 23:28:41 +0100 Subject: [PATCH 127/148] Misc const instead of function --- src/utils/nodes.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/nodes.ts b/src/utils/nodes.ts index 308f28f..46a8830 100644 --- a/src/utils/nodes.ts +++ b/src/utils/nodes.ts @@ -203,12 +203,12 @@ export function addNodeAndEdgeThroughPorts( * * Similar to reaflow.upsertNode utility. */ -export function upsertNodeThroughPorts( +export const upsertNodeThroughPorts = ( nodes: BaseNodeData[], edges: BaseEdgeData[], edge: BaseEdgeData, newNode: BaseNodeData, -): CanvasDataset { +): CanvasDataset => { const oldEdgeIndex = edges.findIndex(e => e.id === edge.id); const edgeBeforeNewNode = { ...edge, @@ -245,7 +245,7 @@ export function upsertNodeThroughPorts( * * Similar to reaflow.removeAndUpsertNodes utility. */ -export function removeAndUpsertNodesThroughPorts( +export const removeAndUpsertNodesThroughPorts = ( nodes: BaseNodeData[], edges: BaseEdgeData[], removeNodes: BaseNodeData | BaseNodeData[], @@ -256,7 +256,7 @@ export function removeAndUpsertNodesThroughPorts( to: BaseNodeData, port?: BasePortData, ) => undefined | boolean, -): CanvasDataset { +): CanvasDataset => { if (!Array.isArray(removeNodes)) { removeNodes = [removeNodes]; } From 0d2e037a396fb5d0943884d2176b40577a9da3f7 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 23:32:57 +0100 Subject: [PATCH 128/148] Refactor addNodeAndEdgeThroughPorts to return only what's changed instead of full dataset (queued) --- src/components/ports/BasePort.tsx | 36 +++++++++++++++++++++++++------ src/utils/nodes.ts | 20 ++++++++--------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/components/ports/BasePort.tsx b/src/components/ports/BasePort.tsx index ea39ff5..02e0edf 100644 --- a/src/components/ports/BasePort.tsx +++ b/src/components/ports/BasePort.tsx @@ -26,7 +26,7 @@ import { selectedEdgesSelector } from '../../states/selectedEdgesState'; import { selectedNodesSelector } from '../../states/selectedNodesState'; import BaseEdgeData from '../../types/BaseEdgeData'; import BaseNodeData from '../../types/BaseNodeData'; -import BasePortChildProps, { AdditionalPortChildProps } from '../../types/BasePortChildProps'; +import BasePortChildProps from '../../types/BasePortChildProps'; import BasePortData from '../../types/BasePortData'; import BasePortProps from '../../types/BasePortProps'; import BlockPickerMenu, { OnBlockClick } from '../../types/BlockPickerMenu'; @@ -38,6 +38,7 @@ import { translateXYToCanvasPosition } from '../../utils/canvas'; import { createEdge } from '../../utils/edges'; import { addNodeAndEdgeThroughPorts, + AddNodeAndEdgeThroughPortsResult, createNodeFromDefaultProps, getDefaultNodePropsWithFallback, } from '../../utils/nodes'; @@ -104,6 +105,7 @@ const BasePort: React.FunctionComponent = (props) => { const newNode = createNodeFromDefaultProps(getDefaultNodePropsWithFallback(nodeType)); let newDataset: CanvasDataset; let createNodeOnSide: PortSide | undefined; + let result; if (typeof draggedEdgeFromPort === 'undefined') { console.log(`typeof draggedEdgeFromPort === 'undefined'`); @@ -126,18 +128,40 @@ const BasePort: React.FunctionComponent = (props) => { // The from port is either the port where the node was dragged from, or the port that was clicked on const fromPort: BasePortData = (draggedEdgeFromPort?.fromPort || blockPickerMenu?.fromPort) as BasePortData; - newDataset = addNodeAndEdgeThroughPorts(cloneDeep(nodes), cloneDeep(edges), newNode, node, newNode, fromPort); + result = addNodeAndEdgeThroughPorts(cloneDeep(nodes), cloneDeep(edges), newNode, node, newNode, fromPort); } else { // The drag started from a WEST port, so we must add the new node on the left of the existing node const fromPort: BasePortData = newNode?.ports?.find((port: BasePortData) => port?.side === 'EAST') as BasePortData; const toPort: BasePortData = draggedEdgeFromPort?.fromPort as BasePortData; - newDataset = addNodeAndEdgeThroughPorts(cloneDeep(nodes), cloneDeep(edges), newNode, newNode, node, fromPort, toPort); + result = addNodeAndEdgeThroughPorts(cloneDeep(nodes), cloneDeep(edges), newNode, newNode, node, fromPort, toPort); + } + console.log('addNodeAndEdge fromNode:', newNode, 'toNode:', node, 'result:', result); + const { nodeToAdd, edgeToAdd }: AddNodeAndEdgeThroughPortsResult = result; + + const mutation: NewCanvasDatasetMutation = { + operationType: 'add', + elementId: nodeToAdd?.id, + elementType: 'node', + changes: nodeToAdd, + }; + + console.log('Adding node add to the queue', 'mutation:', mutation); + addCanvasDatasetMutation(mutation); + + // edgeToAdd can be null + if (edgeToAdd) { + const mutation: NewCanvasDatasetMutation = { + operationType: 'add', + elementId: edgeToAdd?.id, + elementType: 'edge', + changes: edgeToAdd, + }; + + console.log('Adding edge add to the queue', 'mutation:', mutation); + addCanvasDatasetMutation(mutation); } - console.log('addNodeAndEdge fromNode', newNode, 'toNode', node, 'dataset', newDataset); - console.log('newDataset', newDataset); - setCanvasDataset(newDataset); setLastCreatedNode({ node: newNode, at: now() }); setSelectedNodes([newNode?.id]); setSelectedEdges([]); diff --git a/src/utils/nodes.ts b/src/utils/nodes.ts index 46a8830..507e9f6 100644 --- a/src/utils/nodes.ts +++ b/src/utils/nodes.ts @@ -21,6 +21,11 @@ import { getDefaultToPort, } from './ports'; +export type AddNodeAndEdgeThroughPortsResult = { + nodeToAdd: BaseNodeData, + edgeToAdd: BaseEdgeData | null +}; + /** * Creates a new node and returns it. * @@ -163,7 +168,7 @@ export const isNodeReachable = (node: BaseNodeData, edges: BaseEdgeData[]) => { * * Similar to reaflow.addNodeAndEdge utility. */ -export function addNodeAndEdgeThroughPorts( +export const addNodeAndEdgeThroughPorts = ( nodes: BaseNodeData[], edges: BaseEdgeData[], newNode: BaseNodeData, @@ -171,7 +176,7 @@ export function addNodeAndEdgeThroughPorts( toNode?: BaseNodeData, fromPort?: BasePortData, toPort?: BasePortData, -): CanvasDataset { +): AddNodeAndEdgeThroughPortsResult => { // The default destination node is the newly created node toNode = toNode || newNode; @@ -183,15 +188,8 @@ export function addNodeAndEdgeThroughPorts( ); return { - nodes: [...nodes, newNode], - edges: [ - ...edges, - ...(fromNode ? - [ - newEdge, - ] - : []), - ], + nodeToAdd: newNode, + edgeToAdd: fromNode ? newEdge : null, }; } From bb86863da90d087e467ac00f52dcd6e94eb2fb5b Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 23:40:47 +0100 Subject: [PATCH 129/148] Refactoring, move business logic in addNodeAndEdgeThroughPorts --- src/components/ports/BasePort.tsx | 31 ++++++++-------------------- src/utils/nodes.ts | 34 +++++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/components/ports/BasePort.tsx b/src/components/ports/BasePort.tsx index 02e0edf..8bdb294 100644 --- a/src/components/ports/BasePort.tsx +++ b/src/components/ports/BasePort.tsx @@ -137,29 +137,14 @@ const BasePort: React.FunctionComponent = (props) => { result = addNodeAndEdgeThroughPorts(cloneDeep(nodes), cloneDeep(edges), newNode, newNode, node, fromPort, toPort); } console.log('addNodeAndEdge fromNode:', newNode, 'toNode:', node, 'result:', result); - const { nodeToAdd, edgeToAdd }: AddNodeAndEdgeThroughPortsResult = result; - - const mutation: NewCanvasDatasetMutation = { - operationType: 'add', - elementId: nodeToAdd?.id, - elementType: 'node', - changes: nodeToAdd, - }; - - console.log('Adding node add to the queue', 'mutation:', mutation); - addCanvasDatasetMutation(mutation); - - // edgeToAdd can be null - if (edgeToAdd) { - const mutation: NewCanvasDatasetMutation = { - operationType: 'add', - elementId: edgeToAdd?.id, - elementType: 'edge', - changes: edgeToAdd, - }; - - console.log('Adding edge add to the queue', 'mutation:', mutation); - addCanvasDatasetMutation(mutation); + const { nodeMutation, edgeMutation }: AddNodeAndEdgeThroughPortsResult = result; + + console.log('Adding node/edge mutations to the queue', 'node:', nodeMutation, 'edge:', edgeMutation); + addCanvasDatasetMutation(nodeMutation); + + // edgeMutation can be null + if (edgeMutation) { + addCanvasDatasetMutation(edgeMutation); } setLastCreatedNode({ node: newNode, at: now() }); diff --git a/src/utils/nodes.ts b/src/utils/nodes.ts index 507e9f6..ce444a3 100644 --- a/src/utils/nodes.ts +++ b/src/utils/nodes.ts @@ -13,6 +13,7 @@ import BaseNodeData from '../types/BaseNodeData'; import { BaseNodeDefaultProps } from '../types/BaseNodeDefaultProps'; import BasePortData from '../types/BasePortData'; import { CanvasDataset } from '../types/CanvasDataset'; +import { NewCanvasDatasetMutation } from '../types/CanvasDatasetMutation'; import { GetBaseNodeDefaultProps } from '../types/GetBaseNodeDefaultProps'; import NodeType from '../types/NodeType'; import { createEdge } from './edges'; @@ -22,8 +23,8 @@ import { } from './ports'; export type AddNodeAndEdgeThroughPortsResult = { - nodeToAdd: BaseNodeData, - edgeToAdd: BaseEdgeData | null + nodeMutation: NewCanvasDatasetMutation, + edgeMutation: NewCanvasDatasetMutation | null }; /** @@ -176,7 +177,7 @@ export const addNodeAndEdgeThroughPorts = ( toNode?: BaseNodeData, fromPort?: BasePortData, toPort?: BasePortData, -): AddNodeAndEdgeThroughPortsResult => { +): AddNodeAndEdgeThroughPortsResult => { // The default destination node is the newly created node toNode = toNode || newNode; @@ -187,11 +188,28 @@ export const addNodeAndEdgeThroughPorts = ( getDefaultToPort(toNode, toPort), ); + const nodeMutation: NewCanvasDatasetMutation = { + operationType: 'add', + elementId: newNode?.id, + elementType: 'node', + changes: newNode, + }; + + let edgeMutation: NewCanvasDatasetMutation | null = null; + if (fromNode) { + edgeMutation = { + operationType: 'add', + elementId: newEdge?.id, + elementType: 'edge', + changes: newEdge, + }; + } + return { - nodeToAdd: newNode, - edgeToAdd: fromNode ? newEdge : null, + nodeMutation, + edgeMutation, }; -} +}; /** * Helper function for upserting a node in a edge (split the edge in 2 and put the node in between), and automatically link their ports. @@ -236,7 +254,7 @@ export const upsertNodeThroughPorts = ( nodes: [...nodes, newNode], edges: [...edges], }; -} +}; /** * Removes a node between two edges and merges the two edges into one, and automatically link their ports. @@ -304,4 +322,4 @@ export const removeAndUpsertNodesThroughPorts = ( edges: newEdges, nodes: newNodes, }; -} +}; From fd4d9121b77c9220273dd53a8856d033fda54be6 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 23:54:13 +0100 Subject: [PATCH 130/148] Refactoring upsertNodeThroughPorts to use batch mutations --- src/components/edges/BaseEdge.tsx | 7 +++-- src/utils/canvasDatasetMutationsQueue.ts | 2 +- src/utils/nodes.ts | 40 ++++++++++++++++++++---- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/components/edges/BaseEdge.tsx b/src/components/edges/BaseEdge.tsx index 6a91927..90e05b2 100644 --- a/src/components/edges/BaseEdge.tsx +++ b/src/components/edges/BaseEdge.tsx @@ -25,7 +25,6 @@ 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'; @@ -105,9 +104,11 @@ const BaseEdge: React.FunctionComponent = (props) => { const onBlockClick: OnBlockClick = (nodeType: NodeType) => { 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) => addCanvasDatasetMutation(mutation)); - setCanvasDataset(newDataset); setLastCreatedNode({ node: newNode, at: now() }); setSelectedNodes([newNode?.id]); setSelectedEdges([]); diff --git a/src/utils/canvasDatasetMutationsQueue.ts b/src/utils/canvasDatasetMutationsQueue.ts index 70423d4..098c30c 100644 --- a/src/utils/canvasDatasetMutationsQueue.ts +++ b/src/utils/canvasDatasetMutationsQueue.ts @@ -120,7 +120,7 @@ export const applyPendingMutations: ApplyPendingMutations = ({ nodes, edges, mut } }); - console.log('Saving new dataset (batch)', { + console.log('Saving new dataset (mutations batch)', { nodes: newNodes, edges: newEdges, }); diff --git a/src/utils/nodes.ts b/src/utils/nodes.ts index ce444a3..395ace5 100644 --- a/src/utils/nodes.ts +++ b/src/utils/nodes.ts @@ -27,6 +27,8 @@ export type AddNodeAndEdgeThroughPortsResult = { edgeMutation: NewCanvasDatasetMutation | null }; +export type UpsertNodeThroughPortsResult = NewCanvasDatasetMutation[]; + /** * Creates a new node and returns it. * @@ -224,8 +226,7 @@ export const upsertNodeThroughPorts = ( edges: BaseEdgeData[], edge: BaseEdgeData, newNode: BaseNodeData, -): CanvasDataset => { - const oldEdgeIndex = edges.findIndex(e => e.id === edge.id); +): UpsertNodeThroughPortsResult => { const edgeBeforeNewNode = { ...edge, id: `${edge.from}-${newNode.id}`, @@ -248,12 +249,39 @@ export const upsertNodeThroughPorts = ( edgeAfterNewNode.toPort = edge.toPort; } - edges.splice(oldEdgeIndex, 1, edgeBeforeNewNode, edgeAfterNewNode); + const nodeMutation: NewCanvasDatasetMutation = { + operationType: 'add', + elementId: newNode?.id, + elementType: 'node', + changes: newNode, + }; - return { - nodes: [...nodes, newNode], - edges: [...edges], + const edgeBeforeNewNodeMutation: NewCanvasDatasetMutation = { + operationType: 'add', + elementId: edgeBeforeNewNode?.id, + elementType: 'edge', + changes: edgeBeforeNewNode, + }; + + const edgeAfterNewNodeMutation: NewCanvasDatasetMutation = { + operationType: 'add', + elementId: edgeAfterNewNode?.id, + elementType: 'edge', + changes: edgeAfterNewNode, }; + + const oldEdgeMutation: NewCanvasDatasetMutation = { + operationType: 'delete', + elementId: edge?.id, + elementType: 'edge', + }; + + return [ + nodeMutation, + edgeBeforeNewNodeMutation, + edgeAfterNewNodeMutation, + oldEdgeMutation, + ] }; /** From 44503e65ee2426ad7d1bf567380163494e5a6bd4 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 23:57:47 +0100 Subject: [PATCH 131/148] Rename addCanvasDatasetMutation > queueCanvasDatasetMutation --- src/components/edges/BaseEdge.tsx | 8 ++++---- src/components/editor/CanvasContainer.tsx | 8 ++++---- src/components/nodes/BaseNode.tsx | 10 +++++----- src/components/nodes/NodeRouter.tsx | 8 ++++---- src/components/ports/BasePort.tsx | 8 ++++---- src/types/BaseEdgeProps.ts | 4 ++-- src/types/BaseNodeProps.ts | 4 ++-- src/types/BasePortProps.ts | 4 ++-- src/types/CanvasDatasetMutation.ts | 2 +- src/types/nodes/SpecializedNodeProps.ts | 4 ++-- 10 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/components/edges/BaseEdge.tsx b/src/components/edges/BaseEdge.tsx index 90e05b2..17a9e0f 100644 --- a/src/components/edges/BaseEdge.tsx +++ b/src/components/edges/BaseEdge.tsx @@ -55,7 +55,7 @@ const BaseEdge: React.FunctionComponent = (props) => { sourcePort: sourcePortId, target: targetNodeId, targetPort: targetPortId, - addCanvasDatasetMutation, + queueCanvasDatasetMutation, } = props; // console.log('props', props) @@ -107,7 +107,7 @@ const BaseEdge: React.FunctionComponent = (props) => { const mutations: NewCanvasDatasetMutation[] = upsertNodeThroughPorts(cloneDeep(nodes), cloneDeep(edges), edge, newNode); // Apply all mutations - mutations.map((mutation) => addCanvasDatasetMutation(mutation)); + mutations.map((mutation) => queueCanvasDatasetMutation(mutation)); setLastCreatedNode({ node: newNode, at: now() }); setSelectedNodes([newNode?.id]); @@ -145,7 +145,7 @@ const BaseEdge: React.FunctionComponent = (props) => { }; console.log('Adding edge patch to the queue', 'mutation:', mutation); - addCanvasDatasetMutation(mutation); + queueCanvasDatasetMutation(mutation); }; /** @@ -180,7 +180,7 @@ const BaseEdge: React.FunctionComponent = (props) => { }; console.log('Adding edge patch to the queue', 'patch:', patch, 'mutation:', mutation); - addCanvasDatasetMutation(mutation, stateUpdateDelay); + queueCanvasDatasetMutation(mutation, stateUpdateDelay); }; return ( diff --git a/src/components/editor/CanvasContainer.tsx b/src/components/editor/CanvasContainer.tsx index d65a25e..9180788 100644 --- a/src/components/editor/CanvasContainer.tsx +++ b/src/components/editor/CanvasContainer.tsx @@ -29,7 +29,7 @@ import { selectedNodesSelector } from '../../states/selectedNodesState'; import { UserSession } from '../../types/auth/UserSession'; import BaseNodeData from '../../types/BaseNodeData'; import { CanvasDataset } from '../../types/CanvasDataset'; -import { AddCanvasDatasetMutation } from '../../types/CanvasDatasetMutation'; +import { QueueCanvasDatasetMutation } from '../../types/CanvasDatasetMutation'; import { TypeOfRef } from '../../types/faunadb/TypeOfRef'; import { applyPendingMutations, @@ -129,7 +129,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n * @param patch * @param stateUpdateDelay (ms) */ - const addCanvasDatasetMutation: AddCanvasDatasetMutation = (patch, stateUpdateDelay = 0) => { + const queueCanvasDatasetMutation: QueueCanvasDatasetMutation = (patch, stateUpdateDelay = 0) => { mutationsQueue.push({ status: 'pending', id: uuid(), @@ -303,7 +303,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n return ( ); }; @@ -318,7 +318,7 @@ const CanvasContainer: React.FunctionComponent = (props): JSX.Element | n return ( ); }; diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index d7927c1..c7352f6 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -85,7 +85,7 @@ const BaseNode: BaseNodeComponent = (props) => { baseHeight, patchCurrentNodeWait, patchCurrentNodeOptions, - addCanvasDatasetMutation, + queueCanvasDatasetMutation, ...nodeProps // All props that are left will be forwarded to the Node component } = props; @@ -199,7 +199,7 @@ const BaseNode: BaseNodeComponent = (props) => { }; console.log('Adding node patch to the queue', 'patch:', patch, 'mutation:', mutation); - addCanvasDatasetMutation(mutation, stateUpdateDelay); + queueCanvasDatasetMutation(mutation, stateUpdateDelay); }; /** @@ -217,7 +217,7 @@ const BaseNode: BaseNodeComponent = (props) => { }; console.log('Adding patch to the queue', 'node:', clonedNode, 'mutation:', mutation); - addCanvasDatasetMutation(mutation); + queueCanvasDatasetMutation(mutation); }; /** @@ -350,7 +350,7 @@ const BaseNode: BaseNodeComponent = (props) => { isNodeReachable: isReachable, }} PortChildComponent={BasePortChild} - addCanvasDatasetMutation={addCanvasDatasetMutation} + queueCanvasDatasetMutation={queueCanvasDatasetMutation} /> )} > @@ -374,7 +374,7 @@ const BaseNode: BaseNodeComponent = (props) => { lastCreated, patchCurrentNode, patchCurrentNodeImmediately: patchCurrentNode, - addCanvasDatasetMutation, + queueCanvasDatasetMutation, }; return ( diff --git a/src/components/nodes/NodeRouter.tsx b/src/components/nodes/NodeRouter.tsx index 0f32e5a..d3f2a32 100644 --- a/src/components/nodes/NodeRouter.tsx +++ b/src/components/nodes/NodeRouter.tsx @@ -4,12 +4,12 @@ import { useRecoilState } from 'recoil'; import { nodesSelector } from '../../states/nodesState'; import BaseNodeComponent from '../../types/BaseNodeComponent'; import BaseNodeData from '../../types/BaseNodeData'; -import { AddCanvasDatasetMutation } from '../../types/CanvasDatasetMutation'; +import { QueueCanvasDatasetMutation } from '../../types/CanvasDatasetMutation'; import { findNodeComponentByType } from '../../utils/nodes'; type Props = { nodeProps: NodeProps; - addCanvasDatasetMutation: AddCanvasDatasetMutation; + queueCanvasDatasetMutation: QueueCanvasDatasetMutation; } /** @@ -20,7 +20,7 @@ type Props = { const NodeRouter: React.FunctionComponent = (props) => { const { nodeProps, - addCanvasDatasetMutation, + queueCanvasDatasetMutation, } = props; const nodeType = nodeProps?.properties?.data?.type; const [nodes, setNodes] = useRecoilState(nodesSelector); @@ -50,7 +50,7 @@ const NodeRouter: React.FunctionComponent = (props) => { ); }; diff --git a/src/components/ports/BasePort.tsx b/src/components/ports/BasePort.tsx index 8bdb294..4bf6f32 100644 --- a/src/components/ports/BasePort.tsx +++ b/src/components/ports/BasePort.tsx @@ -68,7 +68,7 @@ const BasePort: React.FunctionComponent = (props) => { PortChildComponent, onDragStart: onDragStartInternal, onDragEnd: onDragEndInternal, - addCanvasDatasetMutation, + queueCanvasDatasetMutation, } = props; const [blockPickerMenu, setBlockPickerMenu] = useRecoilState(blockPickerMenuSelector); @@ -140,11 +140,11 @@ const BasePort: React.FunctionComponent = (props) => { const { nodeMutation, edgeMutation }: AddNodeAndEdgeThroughPortsResult = result; console.log('Adding node/edge mutations to the queue', 'node:', nodeMutation, 'edge:', edgeMutation); - addCanvasDatasetMutation(nodeMutation); + queueCanvasDatasetMutation(nodeMutation); // edgeMutation can be null if (edgeMutation) { - addCanvasDatasetMutation(edgeMutation); + queueCanvasDatasetMutation(edgeMutation); } setLastCreatedNode({ node: newNode, at: now() }); @@ -246,7 +246,7 @@ const BasePort: React.FunctionComponent = (props) => { }; console.log('Adding edge patch to the queue', 'mutation:', mutation); - addCanvasDatasetMutation(mutation); + queueCanvasDatasetMutation(mutation); } else { console.error(`You cannot connect the link to that port.`); alert(`You cannot connect the link to that port.`); diff --git a/src/types/BaseEdgeProps.ts b/src/types/BaseEdgeProps.ts index 14954c6..a6225ec 100644 --- a/src/types/BaseEdgeProps.ts +++ b/src/types/BaseEdgeProps.ts @@ -1,6 +1,6 @@ import { EdgeProps } from 'reaflow'; import BaseEdgeData from './BaseEdgeData'; -import { AddCanvasDatasetMutation } from './CanvasDatasetMutation'; +import { QueueCanvasDatasetMutation } from './CanvasDatasetMutation'; export type PatchCurrentEdge = Partial> = (patch: Partial) => void; @@ -10,7 +10,7 @@ export type PatchCurrentEdge = Partial & { - addCanvasDatasetMutation: AddCanvasDatasetMutation; + queueCanvasDatasetMutation: QueueCanvasDatasetMutation; }; export default BaseEdgeProps; diff --git a/src/types/BaseNodeProps.ts b/src/types/BaseNodeProps.ts index c5dd0ea..4e919aa 100644 --- a/src/types/BaseNodeProps.ts +++ b/src/types/BaseNodeProps.ts @@ -1,6 +1,6 @@ import { NodeProps } from 'reaflow'; import BaseNodeData from './BaseNodeData'; -import { AddCanvasDatasetMutation } from './CanvasDatasetMutation'; +import { QueueCanvasDatasetMutation } from './CanvasDatasetMutation'; import PartialBaseNodeData from './PartialBaseNodeData'; export type PatchCurrentNode = Partial> = (patch: PartialBaseNodeData, stateUpdateDelay?: number) => void; @@ -17,7 +17,7 @@ export type BaseNodeProps = { /** * TODO */ - addCanvasDatasetMutation: AddCanvasDatasetMutation; + queueCanvasDatasetMutation: QueueCanvasDatasetMutation; } & Partial; export default BaseNodeProps; diff --git a/src/types/BasePortProps.ts b/src/types/BasePortProps.ts index 8f5d516..51fd953 100644 --- a/src/types/BasePortProps.ts +++ b/src/types/BasePortProps.ts @@ -1,7 +1,7 @@ import React from 'react'; import { PortProps } from 'reaflow'; import BasePortChildProps, { AdditionalPortChildProps } from './BasePortChildProps'; -import { AddCanvasDatasetMutation } from './CanvasDatasetMutation'; +import { QueueCanvasDatasetMutation } from './CanvasDatasetMutation'; /** * Props received by any port component (BasePort). @@ -12,7 +12,7 @@ export type BasePortProps = Partial & { fromNodeId: string; additionalPortChildProps: AdditionalPortChildProps; PortChildComponent: React.FunctionComponent; - addCanvasDatasetMutation: AddCanvasDatasetMutation; + queueCanvasDatasetMutation: QueueCanvasDatasetMutation; }; export default BasePortProps; diff --git a/src/types/CanvasDatasetMutation.ts b/src/types/CanvasDatasetMutation.ts index ef84fcc..ebb0a77 100644 --- a/src/types/CanvasDatasetMutation.ts +++ b/src/types/CanvasDatasetMutation.ts @@ -2,7 +2,7 @@ import BaseEdgeData from './BaseEdgeData'; import PartialBaseNodeData from './PartialBaseNodeData'; export type NewCanvasDatasetMutation = Omit -export type AddCanvasDatasetMutation = (mutation: NewCanvasDatasetMutation, stateUpdateDelay?: number) => void; +export type QueueCanvasDatasetMutation = (mutation: NewCanvasDatasetMutation, stateUpdateDelay?: number) => void; export type CanvasDatasetMutation = { /** diff --git a/src/types/nodes/SpecializedNodeProps.ts b/src/types/nodes/SpecializedNodeProps.ts index 691a253..e5cab60 100644 --- a/src/types/nodes/SpecializedNodeProps.ts +++ b/src/types/nodes/SpecializedNodeProps.ts @@ -1,7 +1,7 @@ import { NodeChildProps } from 'reaflow'; import BaseNodeData from '../BaseNodeData'; import { PatchCurrentNode } from '../BaseNodeProps'; -import { AddCanvasDatasetMutation } from '../CanvasDatasetMutation'; +import { QueueCanvasDatasetMutation } from '../CanvasDatasetMutation'; import { LastCreated } from '../LastCreated'; /** @@ -50,6 +50,6 @@ export type SpecializedNodeProps = /** * TODO */ - addCanvasDatasetMutation: AddCanvasDatasetMutation; + queueCanvasDatasetMutation: QueueCanvasDatasetMutation; } From 44d1e22177b6194d1dbdf8a5ea52e4d4e9c0c498 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Wed, 17 Mar 2021 23:58:11 +0100 Subject: [PATCH 132/148] Misc doc --- src/types/BaseNodeProps.ts | 2 +- src/types/nodes/SpecializedNodeProps.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/BaseNodeProps.ts b/src/types/BaseNodeProps.ts index 4e919aa..0c54da9 100644 --- a/src/types/BaseNodeProps.ts +++ b/src/types/BaseNodeProps.ts @@ -15,7 +15,7 @@ export type BaseNodeProps = { node: NodeData; /** - * TODO + * Adds a new patch to apply to the existing queue. */ queueCanvasDatasetMutation: QueueCanvasDatasetMutation; } & Partial; diff --git a/src/types/nodes/SpecializedNodeProps.ts b/src/types/nodes/SpecializedNodeProps.ts index e5cab60..6760b38 100644 --- a/src/types/nodes/SpecializedNodeProps.ts +++ b/src/types/nodes/SpecializedNodeProps.ts @@ -48,7 +48,7 @@ export type SpecializedNodeProps = isReachable: boolean; /** - * TODO + * Adds a new patch to apply to the existing queue. */ queueCanvasDatasetMutation: QueueCanvasDatasetMutation; From 2bd3c6fad5d23fcebd620e5147dbfdb7b6db7b03 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 18 Mar 2021 00:27:17 +0100 Subject: [PATCH 133/148] Add textarea width settings --- src/components/nodes/BaseNode.tsx | 2 +- src/settings.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index c7352f6..2ca5b3d 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -458,7 +458,7 @@ const BaseNode: BaseNodeComponent = (props) => { .textarea { margin-top: 15px; background-color: #F1F3FF; - border: 1px solid lightgrey; + border: ${settings.canvas.nodes.textarea.borderWidth}px solid lightgrey; border-radius: 5px; } `} diff --git a/src/settings.ts b/src/settings.ts index 97ddafb..275ae32 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -26,6 +26,9 @@ export const settings: Settings = { selected: { borderColor: 'rgba(0, 40, 255, 0.2)', }, + textarea: { + borderWidth: 1, + }, questionNode: { choiceTypeOptions: [ { @@ -127,6 +130,18 @@ export type CanvasSettings = { */ borderWidth: 2; + /** + * Applies to all textarea. + */ + textarea: { + /** + * Width of the textarea border. + * + * Must be multiplied by 2 to have the top + bottom width. + */ + borderWidth: number; + }; + selected: { /** * Border color when the node is selected. From 38fb044f27614b60241d42e68c65653d0af0f2de Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 18 Mar 2021 00:27:29 +0100 Subject: [PATCH 134/148] Add todo --- src/utils/nodes.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/nodes.ts b/src/utils/nodes.ts index 395ace5..f60e4f1 100644 --- a/src/utils/nodes.ts +++ b/src/utils/nodes.ts @@ -288,6 +288,8 @@ export const upsertNodeThroughPorts = ( * Removes a node between two edges and merges the two edges into one, and automatically link their ports. * * Similar to reaflow.removeAndUpsertNodes utility. + * + * TODO should return mutations too, but I'm lazy (and it's unlikely that it'll ever cause concurrent updates conflicts anyway) */ export const removeAndUpsertNodesThroughPorts = ( nodes: BaseNodeData[], From 621e4b3c9b0909bcb0e64d79bb3d033e0fe9fae2 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 18 Mar 2021 00:27:55 +0100 Subject: [PATCH 135/148] Remove deep-diff lib, use deep-object-diff instead --- package.json | 2 -- src/utils/canvasStream.ts | 17 +++++++++++------ yarn.lock | 7 +------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index f2d28ef..92f9b7a 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "animate.css": "4.1.1", "classnames": "2.2.6", "cookie": "0.4.1", - "deep-diff": "1.0.2", "deep-object-diff": "1.1.0", "faunadb": "4.1.1", "faunadb-fql-lib": "0.13.0", @@ -69,7 +68,6 @@ "@emotion/babel-plugin": "11.1.2", "@types/classnames": "2.2.11", "@types/cookie": "0.4.0", - "@types/deep-diff": "1.0.0", "@types/lodash.capitalize": "4.2.6", "@types/lodash.debounce": "4.0.6", "@types/lodash.filter": "4.6.6", diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 4d6fbcc..2df43cd 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -1,4 +1,3 @@ -import { diff } from 'deep-diff'; import { Client, Collection, @@ -32,6 +31,10 @@ import { } from '../types/faunadb/CanvasStream'; import { FaunadbStreamVersionEvent } from '../types/faunadb/FaunadbStreamVersionEvent'; import { TypeOfRef } from '../types/faunadb/TypeOfRef'; +import { + diff, + detailedDiff +} from 'deep-object-diff'; const PUBLIC_SHARED_FAUNABD_TOKEN = process.env.NEXT_PUBLIC_SHARED_FAUNABD_TOKEN as string; const SHARED_CANVAS_DOCUMENT_ID = '1'; @@ -247,7 +250,8 @@ export const updateUserCanvas = async (canvasRef: TypeOfRef | undefined, user: P if (hasDatasetChanged(newCanvasDataset)) { // Checking if the previous and new datasets (local) have changed helps avoiding unnecessary database updates const areLocalDatasetsDifferent = !isEqual(previousCanvasDataset, newCanvasDataset); // isEqual performs a deep comparison - const localDiff = diff(previousCanvasDataset, newCanvasDataset); + const localDiff = diff(previousCanvasDataset || {}, newCanvasDataset); + const localDetailedDiff = detailedDiff(previousCanvasDataset || {}, newCanvasDataset); if (areLocalDatasetsDifferent) { // Even when the local dataset has changed, it might just be due to synchronizing from a remote change @@ -264,10 +268,11 @@ export const updateUserCanvas = async (canvasRef: TypeOfRef | undefined, user: P edges: existingRemoteCanvasDatasetResult.data?.edges, }; const isRemoteDatasetsDifferent = !isEqual(existingRemoteCanvasDataset, newCanvasDataset); // isEqual performs a deep comparison - const remoteDiff = diff(previousCanvasDataset, newCanvasDataset); + const remoteDiff = diff(previousCanvasDataset || {}, newCanvasDataset); + const remoteDetailedDiff = detailedDiff(previousCanvasDataset || {}, newCanvasDataset); if (isRemoteDatasetsDifferent) { - console.debug('[updateUserCanvas] Updating canvas dataset in FaunaDB. Old:', previousCanvasDataset, 'new:', newCanvasDataset, 'diff:', remoteDiff); + console.debug('[updateUserCanvas] Updating canvas dataset in FaunaDB. Old:', previousCanvasDataset, 'new:', newCanvasDataset, 'diff:', remoteDiff, 'detailedDiff:', remoteDetailedDiff); try { const newCanvas: UpdateCanvas = { @@ -293,10 +298,10 @@ export const updateUserCanvas = async (canvasRef: TypeOfRef | undefined, user: P } } } else { - console.log(`[updateUserCanvas] Canvas remote dataset has not changed. Database update was aborted.`, 'diff:', remoteDiff); + console.log(`[updateUserCanvas] Canvas remote dataset has not changed. Database update was aborted.`, 'diff:', remoteDiff, 'detailedDiff:', remoteDetailedDiff); } } else { - console.log(`[updateUserCanvas] Canvas local dataset has not changed. Database update was aborted.`, 'diff:', localDiff); + console.log(`[updateUserCanvas] Canvas local dataset has not changed. Database update was aborted.`, 'diff:', localDiff, 'detailedDiff:', localDetailedDiff); } } else { console.log(`[updateUserCanvas] Canvas dataset has changed, although it's a default/empty dataset. Only non-default and non-empty changes are persisted to the DB (optimization). Database update was aborted.`); diff --git a/yarn.lock b/yarn.lock index 9f3c152..69a0ba4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1145,11 +1145,6 @@ resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.0.tgz#14f854c0f93d326e39da6e3b6f34f7d37513d108" integrity sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg== -"@types/deep-diff@1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@types/deep-diff/-/deep-diff-1.0.0.tgz#7eba3202a99b3a207f758f351f7f86387269fc40" - integrity sha512-ENsJcujGbCU/oXhDfQ12mSo/mCBWodT2tpARZKmatoSrf8+cGRCPi0KVj3I0FORhYZfLXkewXu7AoIWqiBLkNw== - "@types/lodash.capitalize@4.2.6": version "4.2.6" resolved "https://registry.yarnpkg.com/@types/lodash.capitalize/-/lodash.capitalize-4.2.6.tgz#261a1c0151872c2eab068b78d9bcd33305a76f92" @@ -2112,7 +2107,7 @@ deep-copy@^1.4.1: resolved "https://registry.yarnpkg.com/deep-copy/-/deep-copy-1.4.2.tgz#0622719257e4bd60240e401ea96718211c5c4697" integrity sha512-VxZwQ/1+WGQPl5nE67uLhh7OqdrmqI1OazrraO9Bbw/M8Bt6Mol/RxzDA6N6ZgRXpsG/W9PgUj8E1LHHBEq2GQ== -deep-diff@1.0.2, deep-diff@^1.0.2: +deep-diff@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-1.0.2.tgz#afd3d1f749115be965e89c63edc7abb1506b9c26" integrity sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg== From 919204fea92cdaf85c2f3956ee30710ca65cba90 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 18 Mar 2021 00:30:08 +0100 Subject: [PATCH 136/148] Ignore textarea border width because it forces recalculations --- src/components/nodes/InformationNode.tsx | 6 ++++-- src/components/nodes/QuestionNode.tsx | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/nodes/InformationNode.tsx b/src/components/nodes/InformationNode.tsx index 450784a..b9dd282 100644 --- a/src/components/nodes/InformationNode.tsx +++ b/src/components/nodes/InformationNode.tsx @@ -65,8 +65,11 @@ const InformationNode: BaseNodeComponent = (props) => { * @param meta */ const onInformationTextHeightChange = (height: number, meta: TextareaHeightChangeMeta) => { + // The height of the input takes the border into account, but it must be subtracted (and multiplied by 2 because top + bottom) + const trueInputHeight = height - (settings.canvas.nodes.textarea.borderWidth * 2); + // Only consider additional height, by ignoring the height of the first row - const additionalHeight = height - meta.rowHeight; + const additionalHeight = trueInputHeight - meta.rowHeight; const patchedNodeAdditionalData: Partial = { dynHeights: { informationTextareaHeight: additionalHeight, @@ -76,7 +79,6 @@ const InformationNode: BaseNodeComponent = (props) => { console.log('onTextHeightChange ', node?.data?.dynHeights?.informationTextareaHeight, newHeight, node?.data?.dynHeights?.informationTextareaHeight !== newHeight); if (node?.data?.dynHeights?.informationTextareaHeight !== newHeight) { - console.log('onTextHeightChange updating height') // Updates the value in the Recoil store patchCurrentNode({ data: patchedNodeAdditionalData, diff --git a/src/components/nodes/QuestionNode.tsx b/src/components/nodes/QuestionNode.tsx index ccbeab4..e6cec5a 100644 --- a/src/components/nodes/QuestionNode.tsx +++ b/src/components/nodes/QuestionNode.tsx @@ -90,8 +90,11 @@ const QuestionNode: BaseNodeComponent = (props) => { * @param meta */ const onQuestionInputHeightChange = (height: number, meta: TextareaHeightChangeMeta) => { + // The height of the input takes the border into account, but it must be subtracted (and multiplied by 2 because top + bottom) + const trueInputHeight = height - (settings.canvas.nodes.textarea.borderWidth * 2); + // Only consider additional height, by ignoring the height of the first row - const additionalHeight = height - meta.rowHeight; + const additionalHeight = trueInputHeight - meta.rowHeight; const patchedNodeAdditionalData: Partial = { dynHeights: { questionTextareaHeight: additionalHeight, From 3593f0b319d636889d503884556347b53c3efe71 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Thu, 18 Mar 2021 00:37:53 +0100 Subject: [PATCH 137/148] Fix onInformationTextHeightChange & cie --- src/components/nodes/InformationNode.tsx | 5 +++-- src/components/nodes/QuestionNode.tsx | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/nodes/InformationNode.tsx b/src/components/nodes/InformationNode.tsx index b9dd282..1293f3b 100644 --- a/src/components/nodes/InformationNode.tsx +++ b/src/components/nodes/InformationNode.tsx @@ -76,9 +76,10 @@ const InformationNode: BaseNodeComponent = (props) => { } as InformationNodeAdditionalData['dynHeights'], }; const newHeight = calculateNodeHeight(patchedNodeAdditionalData.dynHeights); - console.log('onTextHeightChange ', node?.data?.dynHeights?.informationTextareaHeight, newHeight, node?.data?.dynHeights?.informationTextareaHeight !== newHeight); + console.log('onTextHeightChange ', additionalHeight, newHeight); - if (node?.data?.dynHeights?.informationTextareaHeight !== newHeight) { + // If the current additional height stored in the node is different from the new additionalHeight, then update + if (node?.data?.dynHeights?.informationTextareaHeight !== additionalHeight) { // Updates the value in the Recoil store patchCurrentNode({ data: patchedNodeAdditionalData, diff --git a/src/components/nodes/QuestionNode.tsx b/src/components/nodes/QuestionNode.tsx index e6cec5a..36b18d2 100644 --- a/src/components/nodes/QuestionNode.tsx +++ b/src/components/nodes/QuestionNode.tsx @@ -104,9 +104,10 @@ const QuestionNode: BaseNodeComponent = (props) => { ...node?.data?.dynHeights, ...patchedNodeAdditionalData.dynHeights }, displayChoiceInputs); - console.log('onTextHeightChange ', node?.data?.dynHeights?.questionTextareaHeight, newHeight, node?.data?.dynHeights?.questionTextareaHeight !== newHeight); + console.log('onTextHeightChange ', additionalHeight, newHeight); - if (node?.data?.dynHeights?.questionTextareaHeight !== newHeight) { + // If the current additional height stored in the node is different from the new additionalHeight, then update + if (node?.data?.dynHeights?.questionTextareaHeight !== additionalHeight) { // Updates the value in the Recoil store patchCurrentNode({ data: patchedNodeAdditionalData, From 15f6950c9d2facc1391f1df0c0cdacd979b1ec87 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Fri, 19 Mar 2021 21:26:14 +0100 Subject: [PATCH 138/148] Add deploy:fake --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 92f9b7a..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", From ffd1274097cccc3e312d2da0fdf7a7964fa60750 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Fri, 19 Mar 2021 22:01:15 +0100 Subject: [PATCH 139/148] Change CanvasByOwnerIndex shape (was an array of arrays, now it's simply an array of refs) (cherry picked from commit 34f6e98c0913d8e344e630b70785f72cef67b160) --- src/types/faunadb/CanvasByOwnerIndex.ts | 8 ++------ src/utils/canvasStream.ts | 3 +-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/types/faunadb/CanvasByOwnerIndex.ts b/src/types/faunadb/CanvasByOwnerIndex.ts index 4e74287..d9290df 100644 --- a/src/types/faunadb/CanvasByOwnerIndex.ts +++ b/src/types/faunadb/CanvasByOwnerIndex.ts @@ -1,9 +1,5 @@ -import { values } from 'faunadb'; - -export type CanvasByOwnerIndexData = [ - values.Ref, -]; +import { TypeOfRef } from './TypeOfRef'; export type CanvasByOwnerIndex = { - data: CanvasByOwnerIndexData[]; + data: TypeOfRef[]; }; diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 2df43cd..73b283f 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -197,8 +197,7 @@ export const findOrCreateUserCanvas = async (user: Partial): Promis } else { // Return existing canvas reference // Although users could have several canvas (projects), they can only create one and thus we only care about the first - const [canvasRef] = findUserCanvasResult.data[0]; - return canvasRef; + return findUserCanvasResult.data[0]; } } catch (e) { console.error(`[findOrCreateUserCanvas] Error while fetching canvas:`, e); From 953324834b44db8938a8ef4d7f089b4343f1d44f Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Fri, 19 Mar 2021 19:46:43 +0100 Subject: [PATCH 140/148] Fix membership definition for roles --- fql/setup.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fql/setup.js b/fql/setup.js index cb29dc4..437ee94 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -43,9 +43,9 @@ CreateIndex({ CreateRole({ name: 'Editor', // All users should be editors (will apply to authenticated users only). - membership: { + membership: [{ resource: Collection('Users'), - }, + }], privileges: [ { // Editors need read access to the canvas_by_owner index to find their own canvas @@ -103,7 +103,7 @@ CreateRole({ 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: {}, + membership: [], privileges: [ { resource: Collection('Canvas'), From ad5e763bb4382e3f0ec3ca166f3f99e9af2c0147 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Fri, 19 Mar 2021 20:17:35 +0100 Subject: [PATCH 141/148] Fix roles GQL (missing "Query" wrapper) --- fql/setup.js | 77 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/fql/setup.js b/fql/setup.js index 437ee94..6ea2b51 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -71,25 +71,29 @@ CreateRole({ )), ), // Editors should be able to edit only Canvas documents that belongs to them - write: 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")) + 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: Lambda("values", Equals( - CurrentIdentity(), - Select(["data", "owner"], Var("values"))) + create: Query( + Lambda("values", Equals( + CurrentIdentity(), + Select(["data", "owner"], Var("values"))) + ) ), }, }, @@ -119,30 +123,39 @@ CreateRole({ ), ), // Guests should only be allowed to update the Canvas of id "1" - write: Lambda( - ['oldData', 'newData', 'ref'], - Equals( - '1', - Select(['id'], Var('ref')), - ), + 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: Lambda('values', - Equals( - '1', - Select(['ref', 'id'], Var('values')), - ), + 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: Lambda( - ['ref', 'ts', 'action', 'data'], - Equals( - '1', - Select(['id'], Var('ref')), - ), + history_write: Query( + Lambda( + ['ref', 'ts', 'action', 'data'], + Equals( + '1', + Select(['id'], Var('ref')), + ), + ) ), }, }, ], }); + +// Create the shared Canvas record +Create(Collection('Canvas'), { id: "1" }) From c57cc4e6bb34c326df9d85a35183f8e77790dae0 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Fri, 19 Mar 2021 20:37:37 +0100 Subject: [PATCH 142/148] Reformat misc # Conflicts: # fql/setup.js --- fql/setup.js | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/fql/setup.js b/fql/setup.js index 6ea2b51..eb3e03d 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -22,7 +22,9 @@ CreateIndex({ name: 'canvas_by_owner', 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') }, + permissions: { + read: Collection('Users'), + }, // Allow to filter by owner ("Users") terms: [ { field: ['data', 'owner'] }, @@ -43,9 +45,11 @@ CreateIndex({ CreateRole({ name: 'Editor', // All users should be editors (will apply to authenticated users only). - membership: [{ - resource: Collection('Users'), - }], + membership: [ + { + resource: Collection('Users'), + }, + ], privileges: [ { // Editors need read access to the canvas_by_owner index to find their own canvas @@ -73,27 +77,27 @@ CreateRole({ // Editors should be able to edit only Canvas documents that belongs to them write: Query( Lambda( - ["oldData", "newData", "ref"], + ['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")) + Select(['data', 'owner'], Var('oldData')), ), // The owner must not change Equals( - Select(["data", "owner"], Var("oldData")), - Select(["data", "owner"], Var("newData")) - ) - ) - ) + 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( + Lambda('values', Equals( CurrentIdentity(), - Select(["data", "owner"], Var("values"))) - ) + Select(['data', 'owner'], Var('values'))), + ), ), }, }, @@ -130,7 +134,7 @@ CreateRole({ '1', Select(['id'], Var('ref')), ), - ) + ), ), // Guests should only be allowed to create the Canvas of id "1" create: Query( @@ -139,7 +143,7 @@ CreateRole({ '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 @@ -150,7 +154,7 @@ CreateRole({ '1', Select(['id'], Var('ref')), ), - ) + ), ), }, }, From 3b275593034b368ce7bb82a6b6f4cad8e43f461e Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Fri, 19 Mar 2021 20:37:48 +0100 Subject: [PATCH 143/148] Fix create shared canvas --- fql/setup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fql/setup.js b/fql/setup.js index eb3e03d..3dc3ffb 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -162,4 +162,4 @@ CreateRole({ }); // Create the shared Canvas record -Create(Collection('Canvas'), { id: "1" }) +Create(Ref(Collection('Canvas'), '1')); From 9048baf7b0bb818085adfaa2fa50630ac0c0da1d Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Fri, 19 Mar 2021 21:12:58 +0100 Subject: [PATCH 144/148] Add doc getUserByEmail no throw --- src/lib/faunadb/models/userModel.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/faunadb/models/userModel.ts b/src/lib/faunadb/models/userModel.ts index dba880e..d1f540f 100644 --- a/src/lib/faunadb/models/userModel.ts +++ b/src/lib/faunadb/models/userModel.ts @@ -38,6 +38,8 @@ export class UserModel { /** * Find a user using the "users_by_email" index. * + * Don't throw on failures because we want to auto-create users when they don't exist yet. + * * @param email */ async getUserByEmail(email: string): Promise { From 5e025dea84bb31ed54d63e5118ccdb853c01f6c3 Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Fri, 19 Mar 2021 21:13:45 +0100 Subject: [PATCH 145/148] Throw when obtainFaunaDBToken catch an exception --- src/lib/faunadb/models/userModel.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/faunadb/models/userModel.ts b/src/lib/faunadb/models/userModel.ts index d1f540f..f2d602c 100644 --- a/src/lib/faunadb/models/userModel.ts +++ b/src/lib/faunadb/models/userModel.ts @@ -60,9 +60,7 @@ export class UserModel { async obtainFaunaDBToken(user: User): Promise { return faunadbAdminClient?.query( Create(Tokens(), { instance: Select('ref', user) }), - ) - .then((res: FaunadbToken): string | undefined => res?.secret) - .catch(() => undefined); + ).then((res: FaunadbToken): string | undefined => res?.secret); } /** From d3026edbdc2edd7139a28a1cdc66fffddd90d5ce Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Fri, 19 Mar 2021 21:14:42 +0100 Subject: [PATCH 146/148] Split code in login to make it easier to debug --- src/pages/api/login.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts index 0b63b0e..8c3e1f1 100644 --- a/src/pages/api/login.ts +++ b/src/pages/api/login.ts @@ -32,10 +32,17 @@ type EndpointRequest = NextApiRequest & { */ export const login = async (req: EndpointRequest, res: NextApiResponse): Promise => { try { - const didToken = magicAdmin.utils.parseAuthorizationHeader(req?.headers?.authorization || ''); + let didToken: string; - // XXX It is important to always validate the DID Token before using it. Will throw if invalid. - magicAdmin.token.validate(didToken); + try { + didToken = magicAdmin.utils.parseAuthorizationHeader(req?.headers?.authorization || ''); + + // XXX It is important to always validate the DID Token before using it. Will throw if invalid. + magicAdmin.token.validate(didToken); + + } catch (e) { + throw new Error(`Error during Magic Link DID token validation: ${e.message}`); + } // The Magic API returns a few metadata, including the issuer and the user email const userMetadata: MagicUserMetadata = await magicAdmin.users.getMetadataByToken(didToken); @@ -48,14 +55,24 @@ export const login = async (req: EndpointRequest, res: NextApiResponse): Promise throw new Error(`User doesn't have an email. Value: "${userMetadata?.email}"`); } - // Auto-detects new user sign-up when `getUserByEmail` resolves to `undefined` - const user: User = (await userModel.getUserByEmail(userMetadata?.email) ?? await userModel.createUser(userMetadata?.email)) as User; - console.log('Found user', user); + let user: User; + try { + console.log(`Fetching user by email "${userMetadata?.email}"`); + user = await userModel.getUserByEmail(userMetadata?.email) as User; + + if (!user?.data?.email) { + // Auto-detects new user sign-up when `getUserByEmail` resolves to `undefined` + user = await userModel.createUser(userMetadata?.email) as User; + console.log('Automatically created new user.', user); + } + } catch (e) { + throw new Error(`Error while fetching or creating the user: ${e.message}`); + } // Generates a FaunaDB token specific associated to this user const faunaDBToken: string | undefined = await userModel.obtainFaunaDBToken(user); - if(!faunaDBToken){ + if (!faunaDBToken) { // This isn't supposed to happen, because the user cannot not exist. // But it might happen if our "FAUNADB_SERVER_SECRET_KEY" doesn't have the required permission to create a token. // In such case, there is nothing we can do and we should crash early. From a2d3f830bb5f7aada8fda407ca9c451f5cdba80e Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Fri, 19 Mar 2021 21:31:53 +0100 Subject: [PATCH 147/148] Fix lastUpdatedByUserName when anonymous --- src/utils/canvasStream.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 73b283f..8c40b29 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -177,7 +177,7 @@ export const findOrCreateUserCanvas = async (user: Partial): Promis // Indicating who's the editor who's made the change, so we can safely ignore the "version:update" event we'll soon receive // when the DB will notify all subscribed editors about the update lastUpdatedBySessionEphemeralId: user.sessionEphemeralId as string, - lastUpdatedByUserName: user?.email || `Anonymous#${user?.id?.substring(0, 8)}`, + lastUpdatedByUserName: user?.email || `Anonymous#${user?.sessionEphemeralId?.substring(0, 8)}`, }, }; @@ -281,7 +281,7 @@ export const updateUserCanvas = async (canvasRef: TypeOfRef | undefined, user: P // Indicating who's the editor who's made the change, so we can safely ignore the "version:update" event we'll soon receive // when the DB will notify all subscribed editors about the update lastUpdatedBySessionEphemeralId: user.sessionEphemeralId as string, - lastUpdatedByUserName: user?.email || `Anonymous#${user?.id?.substring(0, 8)}`, + lastUpdatedByUserName: user?.email || `Anonymous#${user?.sessionEphemeralId?.substring(0, 8)}`, }, }; const updateCanvasDatasetResult: CanvasDatasetResult = await client.query(Update(canvasRef, newCanvas)); From d86cc29bee7d4bb84f8ccfcbcb10d550a80c243d Mon Sep 17 00:00:00 2001 From: Dhenain Ambroise Date: Mon, 29 Mar 2021 20:01:37 +0200 Subject: [PATCH 148/148] Rename indexes --- fql/setup.js | 8 ++++---- src/lib/faunadb/models/userModel.ts | 4 ++-- src/utils/canvasStream.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/fql/setup.js b/fql/setup.js index 3dc3ffb..6765252 100644 --- a/fql/setup.js +++ b/fql/setup.js @@ -8,7 +8,7 @@ CreateCollection({ name: 'Canvas' }); // Index to filter users by email // Necessary for authentication, to find the user document based on their email CreateIndex({ - name: 'users_by_email', + name: 'usersByEmail', source: Collection('Users'), terms: [ { field: ['data', 'email'] }, @@ -19,7 +19,7 @@ CreateIndex({ // Index to filter canvas by owner // Necessary for real-time subscription, to retrieve the canvas of the current user CreateIndex({ - name: 'canvas_by_owner', + 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: { @@ -52,8 +52,8 @@ CreateRole({ ], privileges: [ { - // Editors need read access to the canvas_by_owner index to find their own canvas - resource: Index('canvas_by_owner'), + // Editors need read access to the canvasByOwner index to find their own canvas + resource: Index('canvasByOwner'), actions: { read: true, }, diff --git a/src/lib/faunadb/models/userModel.ts b/src/lib/faunadb/models/userModel.ts index f2d602c..fa93776 100644 --- a/src/lib/faunadb/models/userModel.ts +++ b/src/lib/faunadb/models/userModel.ts @@ -36,7 +36,7 @@ export class UserModel { } /** - * Find a user using the "users_by_email" index. + * Find a user using the "usersByEmail" index. * * Don't throw on failures because we want to auto-create users when they don't exist yet. * @@ -44,7 +44,7 @@ export class UserModel { */ async getUserByEmail(email: string): Promise { return faunadbAdminClient?.query( - Get(Match(Index('users_by_email'), email)), + Get(Match(Index('usersByEmail'), email)), ).catch(() => undefined); } diff --git a/src/utils/canvasStream.ts b/src/utils/canvasStream.ts index 8c40b29..c506350 100644 --- a/src/utils/canvasStream.ts +++ b/src/utils/canvasStream.ts @@ -154,7 +154,7 @@ export const findOrCreateUserCanvas = async (user: Partial): Promis const client: Client = getUserClient(user); const findUserCanvas = Paginate( Match( - Index('canvas_by_owner'), + Index('canvasByOwner'), Ref(Collection('Users'), user.id), ), );