diff --git a/src/Providers.tsx b/src/Providers.tsx index accfd85c..2093c1c5 100644 --- a/src/Providers.tsx +++ b/src/Providers.tsx @@ -6,7 +6,7 @@ import { Provider, Atom } from "jotai"; import { globalScope } from "@src/atoms/globalScope"; import createCache from "@emotion/cache"; import { CacheProvider } from "@emotion/react"; -import ThemeProvider from "@src/theme/ThemeProvider"; +import RowyThemeProvider from "@src/theme/RowyThemeProvider"; import SnackbarProvider from "@src/contexts/SnackbarContext"; import { Suspense } from "react"; @@ -29,7 +29,7 @@ export default function Providers({ - + }> @@ -37,7 +37,7 @@ export default function Providers({ - + diff --git a/src/atoms/project.ts b/src/atoms/project.ts index bfe254b3..68277ed3 100644 --- a/src/atoms/project.ts +++ b/src/atoms/project.ts @@ -3,6 +3,7 @@ import { sortBy } from "lodash-es"; import { ThemeOptions } from "@mui/material"; import { userRolesAtom } from "./auth"; +import { UpdateFunction } from "./types"; export const projectIdAtom = atom(""); @@ -24,10 +25,13 @@ export type PublicSettings = Partial<{ }>; /** Public settings are visible to unauthenticated users */ export const publicSettingsAtom = atom({}); +/** Stores a function that updates public settings */ +export const updatePublicSettingsAtom = + atom | null>(null); /** Project settings are visible to authenticated users */ export type ProjectSettings = Partial<{ - tables: Array; + tables: TableSettings[]; setupCompleted: boolean; @@ -42,6 +46,9 @@ export type ProjectSettings = Partial<{ }>; /** Project settings are visible to authenticated users */ export const projectSettingsAtom = atom({}); +/** Stores a function that updates project settings */ +export const updateProjectSettingsAtom = + atom | null>(null); /** Table settings stored in project settings */ export type TableSettings = { diff --git a/src/atoms/types.d.ts b/src/atoms/types.d.ts new file mode 100644 index 00000000..8d85b83e --- /dev/null +++ b/src/atoms/types.d.ts @@ -0,0 +1 @@ +export type UpdateFunction = (update: Partial) => Promise; diff --git a/src/atoms/user.ts b/src/atoms/user.ts index bb390720..65db3bb7 100644 --- a/src/atoms/user.ts +++ b/src/atoms/user.ts @@ -6,6 +6,7 @@ import { ThemeOptions } from "@mui/material"; import themes from "@src/theme"; import { publicSettingsAtom } from "./project"; import { TableFilter } from "./table"; +import { UpdateFunction } from "./types"; /** User info and settings */ export type UserSettings = Partial<{ @@ -32,6 +33,10 @@ export type UserSettings = Partial<{ }>; /** User info and settings */ export const userSettingsAtom = atom({}); +/** Stores a function that updates user settings */ +export const updateUserSettingsAtom = atom | null>( + null +); /** * Stores which theme is currently active, based on user or OS setting. diff --git a/src/components/Settings/ThemeColorPicker.tsx b/src/components/Settings/ThemeColorPicker.tsx index 46bb4fb7..35e00f0c 100644 --- a/src/components/Settings/ThemeColorPicker.tsx +++ b/src/components/Settings/ThemeColorPicker.tsx @@ -57,7 +57,7 @@ export default function ThemeColorPicker({ width={244} height={140} color={toColor("hex", light)} - onChange={(c) => () => setLight(c.hex)} + onChange={(c) => setLight(c.hex)} dark={theme.palette.mode === "dark"} /> diff --git a/src/components/Settings/UserSettings/Personalization.tsx b/src/components/Settings/UserSettings/Personalization.tsx index 943d33a7..f6f2fc2c 100644 --- a/src/components/Settings/UserSettings/Personalization.tsx +++ b/src/components/Settings/UserSettings/Personalization.tsx @@ -33,7 +33,7 @@ export default function Personalization({ { setCustomizedThemeColor(e.target.checked); if (!e.target.checked) { @@ -50,7 +50,7 @@ export default function Personalization({ /> - }> + }> { updateSettings({ theme: merge(settings.theme, { diff --git a/src/hooks/useFirestoreDocWithAtom.ts b/src/hooks/useFirestoreDocWithAtom.ts index 014ade85..2322f423 100644 --- a/src/hooks/useFirestoreDocWithAtom.ts +++ b/src/hooks/useFirestoreDocWithAtom.ts @@ -1,17 +1,19 @@ import { useEffect } from "react"; import { useAtom, PrimitiveAtom } from "jotai"; import { Scope } from "jotai/core/atom"; -import { useUpdateAtom } from "jotai/utils"; +import { useUpdateAtom, RESET } from "jotai/utils"; import { doc, DocumentData, onSnapshot, FirestoreError, setDoc, + DocumentReference, } from "firebase/firestore"; import { useErrorHandler } from "react-error-boundary"; import { globalScope } from "@src/atoms/globalScope"; +import { UpdateFunction } from "@src/atoms/types"; import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; /** Options for {@link useFirestoreDocWithAtom} */ @@ -24,6 +26,8 @@ interface IUseFirestoreDocWithAtomOptions { disableSuspense?: boolean; /** Optionally create the document if it doesn’t exist with the following data */ createIfNonExistent?: T; + /** Set this atom’s value to a function that updates the document. Uses same scope as `dataScope`. */ + updateDataAtom?: PrimitiveAtom | null>; } /** @@ -45,11 +49,20 @@ export function useFirestoreDocWithAtom( ) { const [firebaseDb] = useAtom(firebaseDbAtom, globalScope); const setDataAtom = useUpdateAtom(dataAtom, dataScope); + const setUpdateDataAtom = useUpdateAtom( + options?.updateDataAtom || (dataAtom as any), + globalScope + ); const handleError = useErrorHandler(); // Destructure options so they can be used as useEffect dependencies - const { pathSegments, onError, disableSuspense, createIfNonExistent } = - options || {}; + const { + pathSegments, + onError, + disableSuspense, + createIfNonExistent, + updateDataAtom, + } = options || {}; useEffect(() => { if (!path || (Array.isArray(pathSegments) && pathSegments.some((x) => !x))) @@ -63,8 +76,14 @@ export function useFirestoreDocWithAtom( suspended = true; } + const ref = doc( + firebaseDb, + path, + ...((pathSegments as string[]) || []) + ) as DocumentReference; + const unsubscribe = onSnapshot( - doc(firebaseDb, path, ...((pathSegments as string[]) || [])), + ref, (doc) => { try { if (!doc.exists() && !!createIfNonExistent) { @@ -86,8 +105,19 @@ export function useFirestoreDocWithAtom( } ); + // If `options?.updateDataAtom` was passed, + // set the atom’s value to a function that updates the document + if (updateDataAtom) { + setUpdateDataAtom( + () => (update: T) => setDoc(ref, update, { merge: true }) + ); + } + return () => { unsubscribe(); + // If `options?.updateDataAtom` was passed, + // reset the atom’s value to prevent writes + if (updateDataAtom) setUpdateDataAtom(RESET); }; }, [ firebaseDb, @@ -98,6 +128,8 @@ export function useFirestoreDocWithAtom( disableSuspense, createIfNonExistent, handleError, + updateDataAtom, + setUpdateDataAtom, ]); } diff --git a/src/pages/JotaiTest.tsx b/src/pages/JotaiTest.tsx index e9e25e27..f94cce52 100644 --- a/src/pages/JotaiTest.tsx +++ b/src/pages/JotaiTest.tsx @@ -12,6 +12,7 @@ import { } from "@src/atoms/globalScope"; import { firebaseAuthAtom } from "@src/sources/ProjectSourceFirebase"; import { Button } from "@mui/material"; +import MultiSelect from "@rowy/multiselect"; import { useSnackbar } from "notistack"; import { useFirestoreDocWithAtom } from "hooks/useFirestoreDocWithAtom"; @@ -77,6 +78,13 @@ function JotaiTest() { Sign out + i.toString())} + /> + diff --git a/src/pages/Settings/ProjectSettings.tsx b/src/pages/Settings/ProjectSettings.tsx index aed6424a..77fa5921 100644 --- a/src/pages/Settings/ProjectSettings.tsx +++ b/src/pages/Settings/ProjectSettings.tsx @@ -14,7 +14,9 @@ import Customization from "@src/components/Settings/ProjectSettings/Customizatio import { globalScope, projectSettingsAtom, + updateProjectSettingsAtom, publicSettingsAtom, + updatePublicSettingsAtom, } from "@src/atoms/globalScope"; export interface IProjectSettingsChildProps { @@ -30,20 +32,21 @@ export default function ProjectSettingsPage() { const { enqueueSnackbar } = useSnackbar(); - const updateProjectSettings = useDebouncedCallback( - (data: Record) => { - // TODO: - // db - // .doc(path) - // .update(data) - // .then(() => - - enqueueSnackbar("Saved", { variant: "success" }); - - // ) - }, - 1000 + const [_updateProjectSettingsDoc] = useAtom( + updateProjectSettingsAtom, + globalScope ); + const updateProjectSettings = useDebouncedCallback((data) => { + if (_updateProjectSettingsDoc) { + _updateProjectSettingsDoc(data).then(() => + enqueueSnackbar("Saved", { variant: "success" }) + ); + } else { + enqueueSnackbar("Could not update project settings", { + variant: "error", + }); + } + }, 1000); // When the component is to be unmounted, force update settings useEffect( () => () => { @@ -52,17 +55,21 @@ export default function ProjectSettingsPage() { [updateProjectSettings] ); + const [_updatePublicSettingsDoc] = useAtom( + updatePublicSettingsAtom, + globalScope + ); const updatePublicSettings = useDebouncedCallback( (data: Record) => { - // TODO: - // db - // .doc(path) - // .update(data) - // .then(() => - - enqueueSnackbar("Saved", { variant: "success" }); - - // ) + if (_updatePublicSettingsDoc) { + _updatePublicSettingsDoc(data).then(() => + enqueueSnackbar("Saved", { variant: "success" }) + ); + } else { + enqueueSnackbar("Could not update public settings", { + variant: "error", + }); + } }, 1000 ); diff --git a/src/pages/Settings/UserSettings.tsx b/src/pages/Settings/UserSettings.tsx index ac65bb75..75c1e3e3 100644 --- a/src/pages/Settings/UserSettings.tsx +++ b/src/pages/Settings/UserSettings.tsx @@ -15,6 +15,7 @@ import { globalScope, currentUserAtom, userSettingsAtom, + updateUserSettingsAtom, } from "@src/atoms/globalScope"; export interface IUserSettingsChildProps { @@ -27,16 +28,17 @@ export default function UserSettingsPage() { const [userSettings] = useAtom(userSettingsAtom, globalScope); const { enqueueSnackbar } = useSnackbar(); - const updateSettings = useDebouncedCallback((data: Record) => { - // TODO: - // db - // .doc(path) - // .update(data) - // .then(() => - - enqueueSnackbar("Saved", { variant: "success" }); - - // ) + const [_updateUserSettings] = useAtom(updateUserSettingsAtom, globalScope); + const updateSettings = useDebouncedCallback((data) => { + if (_updateUserSettings) { + _updateUserSettings(data).then(() => + enqueueSnackbar("Saved", { variant: "success" }) + ); + } else { + enqueueSnackbar("Could not update project settings", { + variant: "error", + }); + } }, 1000); // When the component is to be unmounted, force update settings useEffect( diff --git a/src/sources/ProjectSourceFirebase.tsx b/src/sources/ProjectSourceFirebase.tsx index 48f2edea..5f15f332 100644 --- a/src/sources/ProjectSourceFirebase.tsx +++ b/src/sources/ProjectSourceFirebase.tsx @@ -15,10 +15,13 @@ import { globalScope, projectIdAtom, projectSettingsAtom, + updateProjectSettingsAtom, publicSettingsAtom, + updatePublicSettingsAtom, currentUserAtom, userRolesAtom, userSettingsAtom, + updateUserSettingsAtom, UserSettings, } from "@src/atoms/globalScope"; import { SETTINGS, PUBLIC_SETTINGS, USERS } from "@src/config/dbPaths"; @@ -120,17 +123,20 @@ export default function ProjectSourceFirebase() { }, [firebaseAuth, setCurrentUser, setUserRoles]); // Store public settings in atom - useFirestoreDocWithAtom(publicSettingsAtom, globalScope, PUBLIC_SETTINGS); + useFirestoreDocWithAtom(publicSettingsAtom, globalScope, PUBLIC_SETTINGS, { + updateDataAtom: updatePublicSettingsAtom, + }); // Store public settings in atom when a user is signed in useFirestoreDocWithAtom( projectSettingsAtom, globalScope, - currentUser ? SETTINGS : undefined + currentUser ? SETTINGS : undefined, + { updateDataAtom: updateProjectSettingsAtom } ); // Store user settings in atom when a user is signed in - useFirestoreDocWithAtom(userSettingsAtom, globalScope, USERS, { + useFirestoreDocWithAtom(userSettingsAtom, globalScope, USERS, { pathSegments: [currentUser?.uid], createIfNonExistent: currentUser ? { @@ -142,6 +148,7 @@ export default function ProjectSourceFirebase() { }, } : undefined, + updateDataAtom: updateUserSettingsAtom, }); return null; diff --git a/src/theme/ThemeProvider.tsx b/src/theme/RowyThemeProvider.tsx similarity index 87% rename from src/theme/ThemeProvider.tsx rename to src/theme/RowyThemeProvider.tsx index c9bb224c..c308694a 100644 --- a/src/theme/ThemeProvider.tsx +++ b/src/theme/RowyThemeProvider.tsx @@ -2,11 +2,7 @@ import { useEffect } from "react"; import { useAtom } from "jotai"; import { Helmet } from "react-helmet-async"; -import { - useMediaQuery, - ThemeProvider as MuiThemeProvider, - CssBaseline, -} from "@mui/material"; +import { useMediaQuery, ThemeProvider, CssBaseline } from "@mui/material"; import Favicon from "@src/assets/Favicon"; import { globalScope } from "@src/atoms/globalScope"; @@ -20,7 +16,7 @@ import { * Injects the MUI theme with customizations from project and user settings. * Also adds dark mode support. */ -export default function ThemeProvider({ +export default function RowyThemeProvider({ children, }: React.PropsWithChildren<{}>) { const [theme, setTheme] = useAtom(themeAtom, globalScope); @@ -55,11 +51,11 @@ export default function ThemeProvider({ )} - + {children} - + ); }