add settings pages

This commit is contained in:
Sidney Alcantara
2022-04-28 16:50:26 +10:00
parent f1b1eb71ab
commit f13eabda54
52 changed files with 2690 additions and 173 deletions

View File

@@ -1,2 +1,3 @@
node_modules/
.yarn
emulators/

View File

@@ -1,22 +1 @@
{
"kind": "identitytoolkit#DownloadAccountResponse",
"users": [
{
"localId": "uhYEjLj2GUl4jNeOkSR6QyesPJb9",
"createdAt": "1650966071948",
"lastLoginAt": "1650969459716",
"providerUserInfo": [
{
"providerId": "google.com",
"rawId": "abc123",
"federatedId": "abc123",
"email": "foo@example.com"
}
],
"validSince": "1650969876",
"email": "foo@example.com",
"emailVerified": true,
"disabled": false
}
]
}
{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"26CJMrwlouNRwkiLofNK07DNgKhw","createdAt":"1651022832613","lastLoginAt":"1651042518099","displayName":"Admin User","photoUrl":"","customAttributes":"{\"roles\": [\"ADMIN\"]}","providerUserInfo":[{"providerId":"google.com","rawId":"abc123","federatedId":"abc123","email":"admin@example.com"}],"validSince":"1651028322","email":"admin@example.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2022-04-28T03:45:32.296Z"},{"localId":"3xTRVPnJGT2GE6lkiWKZp1jShuXj","createdAt":"1651023059442","lastLoginAt":"1651023059443","displayName":"Editor User","providerUserInfo":[{"providerId":"google.com","rawId":"1535779573397289142795231390488730790451","federatedId":"1535779573397289142795231390488730790451","displayName":"Editor User","email":"editor@example.com"}],"validSince":"1651028322","email":"editor@example.com","emailVerified":true,"disabled":false}]}

View File

@@ -1 +1 @@
{ "signIn": { "allowDuplicateEmails": false }, "usageMode": "DEFAULT" }
{"signIn":{"allowDuplicateEmails":false},"usageMode":"DEFAULT"}

View File

@@ -9,4 +9,4 @@
"version": "10.6.0",
"path": "auth_export"
}
}
}

View File

@@ -21,7 +21,7 @@
"port": 9099
},
"firestore": {
"port": 8080
"port": 9299
},
"storage": {
"port": 9199

View File

@@ -14,7 +14,9 @@
"@mui/icons-material": "^5.6.0",
"@mui/lab": "^5.0.0-alpha.76",
"@mui/material": "^5.6.0",
"@mui/styles": "^5.6.2",
"@rowy/multiselect": "^0.2.3",
"compare-versions": "^4.1.3",
"date-fns": "^2.28.0",
"dompurify": "^2.3.6",
"firebase": "^9.6.11",
@@ -23,6 +25,7 @@
"lodash-es": "^4.17.21",
"notistack": "^2.0.4",
"react": "^18.0.0",
"react-color-palette": "^6.2.0",
"react-data-grid": "7.0.0-beta.5",
"react-div-100vh": "^0.7.0",
"react-dom": "^18.0.0",
@@ -30,9 +33,11 @@
"react-error-boundary": "^3.1.4",
"react-helmet-async": "^1.3.0",
"react-router-dom": "^6.3.0",
"react-router-hash-link": "^2.4.3",
"react-scripts": "^5.0.0",
"tss-react": "^3.6.2",
"typescript": "^4.6.3",
"use-debounce": "^7.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
@@ -112,6 +117,7 @@
"@types/react-div-100vh": "^0.4.0",
"@types/react-dom": "^18.0.0",
"@types/react-router-dom": "^5.3.3",
"@types/react-router-hash-link": "^2.4.5",
"@typescript-eslint/parser": "^5.18.0",
"craco-alias": "^3.0.1",
"craco-swc": "^0.5.1",

View File

@@ -1,16 +1,16 @@
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { Routes, Route, Navigate } from "react-router-dom";
import { useAtom } from "jotai";
import Loading from "@src/components/Loading";
import ProjectSourceFirebase from "@src/sources/ProjectSourceFirebase";
import NotFound from "@src/pages/NotFound";
import RequireAuth from "@src/layouts/RequireAuth";
import Nav from "@src/layouts/Nav";
import Navigation from "@src/layouts/Navigation";
import { globalScope } from "@src/atoms/globalScope";
import { currentUserAtom } from "@src/atoms/auth";
import { routes } from "@src/constants/routes";
import { ROUTES } from "@src/constants/routes";
import JotaiTestPage from "@src/pages/JotaiTest";
import SignOutPage from "@src/pages/Auth/SignOut";
@@ -27,6 +27,13 @@ const ImpersonatorAuthPage = lazy(() => import("@src/pages/Auth/ImpersonatorAuth
// prettier-ignore
const SetupPage = lazy(() => import("@src/pages/Setup" /* webpackChunkName: "SetupPage" */));
// prettier-ignore
const UserSettingsPage = lazy(() => import("@src/pages/Settings/UserSettings" /* webpackChunkName: "UserSettingsPage" */));
// prettier-ignore
const ProjectSettingsPage = lazy(() => import("@src/pages/Settings/ProjectSettings" /* webpackChunkName: "ProjectSettingsPage" */));
// prettier-ignore
// const RowyRunTestPage = lazy(() => import("@src/pages/RowyRunTest" /* webpackChunkName: "RowyRunTestPage" */));
export default function App() {
const [currentUser] = useAtom(currentUserAtom, globalScope);
@@ -40,12 +47,12 @@ export default function App() {
<Routes>
<Route path="*" element={<NotFound />} />
<Route path={routes.auth} element={<AuthPage />} />
<Route path={routes.signUp} element={<SignUpPage />} />
<Route path={routes.signOut} element={<SignOutPage />} />
<Route path={routes.jwtAuth} element={<JwtAuthPage />} />
<Route path={ROUTES.auth} element={<AuthPage />} />
<Route path={ROUTES.signUp} element={<SignUpPage />} />
<Route path={ROUTES.signOut} element={<SignOutPage />} />
<Route path={ROUTES.jwtAuth} element={<JwtAuthPage />} />
<Route
path={routes.impersonatorAuth}
path={ROUTES.impersonatorAuth}
element={
<RequireAuth>
<ImpersonatorAuthPage />
@@ -53,17 +60,23 @@ export default function App() {
}
/>
<Route path={routes.setup} element={<SetupPage />} />
<Route path={ROUTES.setup} element={<SetupPage />} />
<Route
path="/"
element={
<RequireAuth>
<Nav />
<Navigation />
</RequireAuth>
}
>
<Route path="dash" element={<div>Dash</div>} />
<Route
path={ROUTES.settings}
element={<Navigate to={ROUTES.userSettings} replace />}
/>
<Route path={ROUTES.userSettings} element={<UserSettingsPage />} />
<Route path={ROUTES.projectSettings} element={<ProjectSettingsPage />} />
{/* <Route path={ROUTES.rowyRunTest} element={<RowyRunTestPage />} /> */}
</Route>
<Route path="/jotaiTest" element={<JotaiTestPage />} />

View File

@@ -24,14 +24,14 @@ export default function LogoRowyRun({
<title id="rowy-run-logo-title">Rowy Run</title>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M32 7.75a6.25 6.25 0 1 1 0 12.5 6.25 6.25 0 0 1 0-12.5Zm0 2a4.25 4.25 0 1 0 0 8.5 4.25 4.25 0 0 0 0-8.5ZM20 20V8h6v2h-4v10h-2Zm24 0 3-9 3 9h2l4-12 5 11.5-2 4.5h2l7-16h-2l-4 9-4-9h-4l-3 9-3-9h-2l-3 9-3-9h-2l4 12h2Z"
fill={theme.palette.text.primary}
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M0 8v10a3 3 0 1 0 6 0v-7h7a3 3 0 1 0 0-6H8a2.997 2.997 0 0 0-2.5 1.341A3 3 0 0 0 0 8Zm10-2H8a2 2 0 0 0-1.995 1.85L6 8v2h4V6Zm-5 4V8a2 2 0 0 0-1.85-1.995L3 6a2 2 0 0 0-1.995 1.85L1 8v2h4Zm0 1H1v4h4v-4Zm-4 5v2a2 2 0 0 0 1.85 1.994L3 20a2 2 0 0 0 1.995-1.85L5 18v-2H1ZM11.001 6H13l.15.005A2 2 0 0 1 15 8l-.005.15A2 2 0 0 1 13 10h-1.999V6Z"
fill={theme.palette.primary.main}
/>

View File

@@ -4,4 +4,5 @@ import type { User } from "firebase/auth";
/** Currently signed in user. `undefined` means loading. */
export const currentUserAtom = atom<User | null | undefined>(undefined);
/** User roles from Firebase Auth user custom claims */
export const userRolesAtom = atom<string[]>([]);

View File

@@ -1,2 +1,9 @@
/** Scope for atoms stored at the root of the app */
export const globalScope = Symbol("globalScope");
export * from "./auth";
export * from "./project";
export * from "./user";
export * from "./ui";
export * from "./rowyRun";

View File

@@ -36,6 +36,7 @@ export type ProjectSettings = Partial<{
rowyRunBuildStatus: "BUILDING" | "COMPLETE";
services: Partial<{
hooks: string;
builder: string;
terminal: string;
}>;
}>;
@@ -60,7 +61,7 @@ export type TableSettings = {
readOnly?: boolean;
};
/** Tables visible to the signed-in user based on roles */
export const tablesAtom = atom((get) => {
export const tablesAtom = atom<TableSettings[]>((get) => {
const userRoles = get(userRolesAtom);
const tables = get(projectSettingsAtom).tables || [];

127
src/atoms/rowyRun.ts Normal file
View File

@@ -0,0 +1,127 @@
import { atom } from "jotai";
import { selectAtom, atomWithStorage } from "jotai/utils";
import { isEqual } from "lodash-es";
import { getIdTokenResult } from "firebase/auth";
import { projectSettingsAtom } from "./project";
import { currentUserAtom } from "./auth";
import { RunRoute } from "@src/constants/runRoutes";
import meta from "@root/package.json";
/**
* Get rowyRunUrl from projectSettings, but only update when this field changes */
const rowyRunUrlAtom = selectAtom(
projectSettingsAtom,
(projectSettings) => projectSettings.rowyRunUrl
);
/**
* Get services from projectSettings, but only update when this field changes
*/
const rowyRunServicesAtom = selectAtom(
projectSettingsAtom,
(projectSettings) => projectSettings.services,
isEqual
);
export interface IRowyRunRequestProps {
/** Optionally force refresh the token */
forceRefresh?: boolean;
service?: "hooks" | "builder";
/** Optionally use Rowy Run instance on localhost */
localhost?: boolean;
route: RunRoute;
body?: any;
/** Params appended to the URL. Will be transforme to a `/`-separated string. */
params?: string[];
/** Parse response as JSON. Default: true */
json?: boolean;
/** Optionally pass an abort signal to abort the request */
signal?: AbortSignal;
}
/**
* An atom that returns a function to call Rowy Run endpoints using the URL
* defined in project settings and retrieving a JWT token
*
* @example Basic usage:
* ```
* const [rowyRun] = useAtom(rowyRunAtom, globalScope);
* ...
* await rowyRun(...);
* ```
*/
export const rowyRunAtom = atom((get) => {
const rowyRunUrl = get(rowyRunUrlAtom);
const rowyRunServices = get(rowyRunServicesAtom);
const currentUser = get(currentUserAtom);
return async ({
forceRefresh,
localhost = false,
service,
route,
params,
body,
signal,
json = true,
}: IRowyRunRequestProps): Promise<Response | any | void> => {
if (!currentUser) {
console.log("Rowy Run: Not signed in");
return;
}
const authToken = await getIdTokenResult(currentUser!, forceRefresh);
const serviceUrl = localhost
? "http://localhost:8080"
: service
? rowyRunServices?.[service]
: rowyRunUrl;
if (!serviceUrl) {
console.log("Rowy Run: Not set up");
return;
}
const { method, path } = route;
let url = serviceUrl + path;
if (params && params.length > 0) url = url + "/" + params.join("/");
const response = await fetch(url, {
method: method,
mode: "cors",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + authToken,
},
redirect: "follow",
referrerPolicy: "no-referrer",
// body data type must match "Content-Type" header
body: body && method !== "GET" ? JSON.stringify(body) : null,
signal,
});
if (json) return await response.json();
return response;
};
});
type RowyRunLatestUpdate = {
lastChecked: string;
rowy: null | Record<string, any>;
rowyRun: null | Record<string, any>;
deployedRowy: string;
deployedRowyRun: string;
};
/** Store latest update from GitHub releases and currently deployed versions */
export const rowyRunLatestUpdateAtom = atomWithStorage<RowyRunLatestUpdate>(
"__ROWY__UPDATE_CHECK",
{
lastChecked: "",
rowy: null,
rowyRun: null,
deployedRowy: meta.version,
deployedRowyRun: "",
}
);

6
src/atoms/ui.ts Normal file
View File

@@ -0,0 +1,6 @@
import { atomWithStorage } from "jotai/utils";
/** Nav open state stored in local storage. */
export const navOpenAtom = atomWithStorage("__ROWY__NAV_OPEN", false);
/** Nav pinned state stored in local storage. */
export const navPinnedAtom = atomWithStorage("__ROWY__NAV_PINNED", false);

View File

@@ -7,13 +7,14 @@ import themes from "@src/theme";
import { publicSettingsAtom } from "./project";
import { TableFilter } from "./table";
/** User info and settings*/
/** User info and settings */
export type UserSettings = Partial<{
/** Synced from user auth info */
user: {
email: string;
displayName?: string;
photoURL?: string;
phoneNumber?: string;
};
roles: string[];
@@ -29,15 +30,20 @@ export type UserSettings = Partial<{
}>
>;
}>;
/** User info and settings*/
/** User info and settings */
export const userSettingsAtom = atom<UserSettings>({});
/** Stores which theme is currently active, based on user or OS setting */
/**
* Stores which theme is currently active, based on user or OS setting.
* Saved in localStorage.
*/
export const themeAtom = atomWithStorage<"light" | "dark">(
"__ROWY__THEME",
"light"
);
/** User can override OS theme */
/**
* User can override OS theme. Saved in localStorage.
*/
export const themeOverriddenAtom = atomWithStorage(
"__ROWY__THEME_OVERRIDDEN",
false

View File

@@ -0,0 +1,70 @@
import { forwardRef } from "react";
import { camelCase } from "lodash-es";
import { HashLink } from "react-router-hash-link";
import { Stack, StackProps, Typography, IconButton } from "@mui/material";
import LinkIcon from "@mui/icons-material/Link";
import { APP_BAR_HEIGHT } from "@src/layouts/Navigation";
export interface ISectionHeadingProps extends Omit<StackProps, "children"> {
children: string;
}
export const SectionHeading = forwardRef(function SectionHeading_(
{ children, sx, ...props }: ISectionHeadingProps,
ref
) {
const sectionLink = camelCase(children);
return (
<Stack
ref={ref}
direction="row"
alignItems="flex-end"
id={sectionLink}
{...props}
sx={{
pb: 0.5,
cursor: "default",
...sx,
position: "relative",
zIndex: 1,
"&:hover .sectionHeadingLink, &:active .sectionHeadingLink": {
opacity: 1,
},
scrollMarginTop: (theme) => theme.spacing(APP_BAR_HEIGHT / 8 + 3.5),
scrollBehavior: "smooth",
}}
>
<Typography variant="subtitle1" component="h2">
{children}
</Typography>
<IconButton
component={HashLink}
to={`#${sectionLink}`}
smooth
size="small"
className="sectionHeadingLink"
sx={{
my: -0.5,
ml: 1,
opacity: 0,
transition: (theme) =>
theme.transitions.create("opacity", {
duration: theme.transitions.duration.short,
}),
"&:focus": { opacity: 1 },
}}
>
<LinkIcon />
</IconButton>
</Stack>
);
});
export default SectionHeading;

View File

@@ -0,0 +1,14 @@
import { Stack, StackProps, Skeleton } from "@mui/material";
export default function SectionHeadingSkeleton({ sx, ...props }: StackProps) {
return (
<Stack
direction="row"
alignItems="flex-end"
{...props}
sx={{ pb: 0.5, ...sx }}
>
<Skeleton width={120} />
</Stack>
);
}

View File

@@ -0,0 +1,156 @@
import { useAtom } from "jotai";
import { Grid, Typography, Button, Link, Divider } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import GitHubIcon from "@mui/icons-material/GitHub";
import DiscordIcon from "@src/assets/icons/Discord";
import TwitterIcon from "@mui/icons-material/Twitter";
import Logo from "@src/assets/Logo";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import meta from "@root/package.json";
import { globalScope, projectIdAtom } from "@src/atoms/globalScope";
import useUpdateCheck from "@src/hooks/useUpdateCheck";
import { EXTERNAL_LINKS, WIKI_LINKS } from "@src/constants/externalLinks";
export default function About() {
const [projectId] = useAtom(projectIdAtom, globalScope);
const [latestUpdate, checkForUpdates, loading] = useUpdateCheck();
return (
<>
<Logo
style={{ display: "block", marginLeft: "auto", marginRight: "auto" }}
/>
<div style={{ marginTop: 12 }}>
<Grid container justifyContent="center" spacing={1}>
<Grid item>
<Button
variant="outlined"
color="secondary"
startIcon={<GitHubIcon viewBox="-1 -1 26 26" color="action" />}
href={EXTERNAL_LINKS.gitHub}
target="_blank"
rel="noopener noreferrer"
>
GitHub
</Button>
</Grid>
<Grid item>
<Button
variant="outlined"
color="secondary"
startIcon={<DiscordIcon color="action" />}
href={EXTERNAL_LINKS.discord}
target="_blank"
rel="noopener noreferrer"
>
Discord
</Button>
</Grid>
<Grid item>
<Button
variant="outlined"
color="secondary"
startIcon={<TwitterIcon color="action" />}
href={EXTERNAL_LINKS.twitter}
target="_blank"
rel="noopener noreferrer"
>
Twitter
</Button>
</Grid>
</Grid>
</div>
<Divider />
<div>
<Grid container spacing={1} alignItems="center" direction="row">
<Grid item xs>
{loading ? (
<Typography display="block">Checking for updates</Typography>
) : latestUpdate.rowy === null ? (
<Typography display="block">Up to date</Typography>
) : (
<Typography display="block">
<span
style={{
display: "inline-block",
backgroundColor: "#f00",
borderRadius: "50%",
width: 10,
height: 10,
marginRight: 4,
}}
/>
Update available:{" "}
<Link
href={latestUpdate.rowy.html_url}
target="_blank"
rel="noopener noreferrer"
>
{latestUpdate.rowy.tag_name}
<InlineOpenInNewIcon />
</Link>
</Typography>
)}
<Typography display="block" color="textSecondary">
Rowy v{meta.version}
</Typography>
</Grid>
<Grid item>
{latestUpdate.rowy === null ? (
<LoadingButton onClick={checkForUpdates} loading={loading}>
Check for updates
</LoadingButton>
) : (
<Button
href={WIKI_LINKS.setupUpdate}
target="_blank"
rel="noopener noreferrer"
>
How to update
<InlineOpenInNewIcon />
</Button>
)}
</Grid>
</Grid>
</div>
<Divider />
<div>
<Grid
container
spacing={1}
alignItems="baseline"
justifyContent="space-between"
>
<Grid item>
<Typography>Firebase project: {projectId}</Typography>
</Grid>
<Grid item>
<Link
href={`https://console.firebase.google.com/project/${projectId}`}
target="_blank"
rel="noopener noreferrer"
variant="body2"
>
Firebase Console
<InlineOpenInNewIcon />
</Link>
</Grid>
</Grid>
</div>
</>
);
}

View File

@@ -0,0 +1,50 @@
import { useState } from "react";
import { startCase } from "lodash-es";
import MultiSelect from "@rowy/multiselect";
import { Typography, Link } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { IProjectSettingsChildProps } from "@src/pages/Settings/ProjectSettings";
import { authOptions } from "@src/config/firebaseui";
export default function Authentication({
publicSettings,
updatePublicSettings,
}: IProjectSettingsChildProps) {
const [signInOptions, setSignInOptions] = useState(
Array.isArray(publicSettings?.signInOptions)
? publicSettings.signInOptions
: ["google"]
);
return (
<>
<MultiSelect
label="Sign-in options"
value={signInOptions}
options={Object.keys(authOptions).map((option) => ({
value: option,
label: startCase(option).replace("Github", "GitHub"),
}))}
onChange={setSignInOptions}
onClose={() => updatePublicSettings({ signInOptions })}
multiple
TextFieldProps={{ id: "signInOptions" }}
/>
<Typography>
Before enabling a new sign-in option, make sure its configured in your
Firebase project.{" "}
<Link
href={`https://github.com/firebase/firebaseui-web#configuring-sign-in-providers`}
target="_blank"
rel="noopener"
>
How to configure sign-in options
<InlineOpenInNewIcon />
</Link>
</Typography>
</>
);
}

View File

@@ -0,0 +1,61 @@
import { lazy, Suspense, useState } from "react";
import { IProjectSettingsChildProps } from "@src/pages/Settings/ProjectSettings";
import { merge, unset } from "lodash-es";
import { FormControlLabel, Checkbox, Collapse } from "@mui/material";
import Loading from "@src/components/Loading";
// prettier-ignore
const ThemeColorPicker = lazy(() => import("@src/components/Settings/ThemeColorPicker") /* webpackChunkName: "ThemeColorPicker" */);
export default function Customization({
publicSettings,
updatePublicSettings,
}: IProjectSettingsChildProps) {
const [customizedThemeColor, setCustomizedThemeColor] = useState(
publicSettings.theme?.light?.palette?.primary?.main ||
publicSettings.theme?.dark?.palette?.primary?.main
);
const handleSave = ({ light, dark }: { light: string; dark: string }) => {
updatePublicSettings({
theme: merge(publicSettings.theme, {
light: { palette: { primary: { main: light } } },
dark: { palette: { primary: { main: dark } } },
}),
});
};
return (
<>
<FormControlLabel
control={
<Checkbox
checked={customizedThemeColor}
onChange={(e) => {
setCustomizedThemeColor(e.target.checked);
if (!e.target.checked) {
const newTheme = publicSettings.theme;
unset(newTheme, "light.palette.primary.main");
unset(newTheme, "dark.palette.primary.main");
updatePublicSettings({ theme: newTheme });
}
}}
/>
}
label="Customize theme colors for all users"
sx={{ my: -10 / 8 }}
/>
<Collapse in={customizedThemeColor} style={{ marginTop: 0 }}>
<Suspense fallback={<Loading />}>
<ThemeColorPicker
currentLight={publicSettings.theme?.light?.palette?.primary?.main}
currentDark={publicSettings.theme?.dark?.palette?.primary?.main}
handleSave={handleSave}
/>
</Suspense>
</Collapse>
</>
);
}

View File

@@ -0,0 +1,196 @@
import { useState } from "react";
import {
Typography,
Link,
Divider,
Button,
Grid,
TextField,
} from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import LogoRowyRun from "@src/assets/LogoRowyRun";
import { IProjectSettingsChildProps } from "@src/pages/Settings/ProjectSettings";
import { EXTERNAL_LINKS, WIKI_LINKS } from "@src/constants/externalLinks";
import useUpdateCheck from "@src/hooks/useUpdateCheck";
import { runRoutes } from "@src/constants/runRoutes";
export default function RowyRun({
settings,
updateSettings,
}: IProjectSettingsChildProps) {
const [inputRowyRunUrl, setInputRowyRunUrl] = useState(settings.rowyRunUrl);
const [verified, setVerified] = useState<boolean | "LOADING" | undefined>();
const handleVerify = async () => {
setVerified("LOADING");
try {
const versionReq = await fetch(inputRowyRunUrl + runRoutes.version.path, {
method: runRoutes.version.method,
}).then((res) => res.json());
if (!versionReq.version) throw new Error("No version found");
else {
setVerified(true);
// If the deployed version is different from the last update check,
// check for updates again to clear update
if (versionReq.version !== latestUpdate.deployedRowyRun)
checkForUpdates();
updateSettings({ rowyRunUrl: inputRowyRunUrl });
}
} catch (e) {
console.error(e);
setVerified(false);
}
};
const [latestUpdate, checkForUpdates, loading] = useUpdateCheck();
const deployButton = (
<Button href={WIKI_LINKS.rowyRun} target="_blank" rel="noopener noreferrer">
Deploy instructions
</Button>
);
return (
<>
<LogoRowyRun
style={{ display: "block", marginLeft: "auto", marginRight: "auto" }}
/>
<Typography style={{ marginTop: 8 }}>
Rowy Run is a Cloud Run instance that provides backend functionality,
such as table action scripts, user management, and easy Cloud Function
deployment.{" "}
<Link
href={WIKI_LINKS.rowyRun}
target="_blank"
rel="noopener noreferrer"
>
Learn more
<InlineOpenInNewIcon />
</Link>
</Typography>
<Divider />
{settings.rowyRunUrl && (
<div>
<Grid container spacing={1} alignItems="center" direction="row">
<Grid item xs>
{loading ? (
<Typography display="block">Checking for updates</Typography>
) : latestUpdate.rowyRun === null ? (
<Typography display="block">Up to date</Typography>
) : (
<Typography display="block">
<span
style={{
display: "inline-block",
backgroundColor: "#f00",
borderRadius: "50%",
width: 10,
height: 10,
marginRight: 4,
}}
/>
Update available:{" "}
<Link
href={latestUpdate.rowyRun.html_url}
target="_blank"
rel="noopener noreferrer"
>
{latestUpdate.rowyRun.tag_name}
<InlineOpenInNewIcon />
</Link>
</Typography>
)}
<Typography display="block" color="textSecondary">
Rowy Run v{latestUpdate.deployedRowyRun}
</Typography>
</Grid>
<Grid item>
{latestUpdate.rowyRun === null ? (
<LoadingButton onClick={checkForUpdates} loading={loading}>
Check for updates
</LoadingButton>
) : (
deployButton
)}
</Grid>
</Grid>
</div>
)}
{settings.rowyRunUrl && <Divider />}
{!settings.rowyRunUrl && (
<div>
<Grid
container
spacing={1}
alignItems="center"
justifyContent="space-between"
>
<Grid item xs={12} sm>
<Typography>
If you have not yet deployed Rowy Run, click this button and
follow the prompts on Cloud Shell.
</Typography>
</Grid>
<Grid item>{deployButton}</Grid>
</Grid>
</div>
)}
<div>
<Grid container spacing={1} alignItems="center" direction="row">
<Grid item xs>
<TextField
label="Cloud Run instance URL"
id="rowyRunUrl"
value={inputRowyRunUrl}
onChange={(e) => setInputRowyRunUrl(e.target.value)}
fullWidth
placeholder="https://<id>.run.app"
type="url"
autoComplete="url"
error={verified === false}
helperText={
verified === true ? (
<>
<CheckCircleIcon
color="success"
style={{ fontSize: "1rem", verticalAlign: "text-top" }}
/>
&nbsp; Rowy Run is set up correctly
</>
) : verified === false ? (
`Rowy Run is not set up correctly`
) : (
" "
)
}
/>
</Grid>
<Grid item>
<LoadingButton
loading={verified === "LOADING"}
onClick={handleVerify}
>
Verify
</LoadingButton>
</Grid>
</Grid>
</div>
</>
);
}

View File

@@ -0,0 +1,43 @@
import { Paper, PaperProps } from "@mui/material";
import SectionHeading from "@src/components/SectionHeading";
import SlideTransition from "@src/components/Modal/SlideTransition";
export interface ISettingsSectionProps {
children: React.ReactNode;
title: string;
paperSx?: PaperProps["sx"];
transitionTimeout?: number;
}
export default function SettingsSection({
children,
title,
paperSx,
transitionTimeout = 100,
}: ISettingsSectionProps) {
return (
<section style={{ cursor: "default" }}>
<SlideTransition in timeout={transitionTimeout}>
<SectionHeading sx={{ mx: 1 }}>{title}</SectionHeading>
</SlideTransition>
<SlideTransition in timeout={transitionTimeout + 50}>
<Paper
sx={{
p: { xs: 2, sm: 3 },
"& > :not(style) + :not(style)": {
m: 0,
mt: { xs: 2, sm: 3 },
},
...paperSx,
}}
>
{children}
</Paper>
</SlideTransition>
</section>
);
}

View File

@@ -0,0 +1,59 @@
import { Typography, Paper, Skeleton, Stack, Divider } from "@mui/material";
export default function SettingsSkeleton() {
return (
<section style={{ cursor: "default" }}>
<Typography variant="subtitle1" component="h2" sx={{ mx: 1, mb: 0.5 }}>
<Skeleton width={120} />
</Typography>
<Paper
sx={{
p: { xs: 2, sm: 3 },
"& > :not(style) + :not(style)": {
m: 0,
mt: { xs: 2, sm: 3 },
},
}}
>
<Stack
spacing={2}
direction="row"
alignItems="center"
justifyContent="space-between"
>
<div>
<Skeleton width={120} />
<Skeleton width={80} />
</div>
<Skeleton
width={100}
variant="rectangular"
sx={{ borderRadius: 1 }}
/>
</Stack>
<Divider />
<Stack
spacing={2}
direction="row"
alignItems="center"
justifyContent="space-between"
>
<div>
<Skeleton width={120} />
<Skeleton width={80} />
</div>
<Skeleton
width={100}
variant="rectangular"
sx={{ borderRadius: 1 }}
/>
</Stack>
</Paper>
</section>
);
}

View File

@@ -0,0 +1,245 @@
import { useState } from "react";
import { ColorPicker, toColor } from "react-color-palette";
import "react-color-palette/lib/css/styles.css";
import { useTheme, Grid, Typography, Stack, Box, Button } from "@mui/material";
import PassIcon from "@mui/icons-material/Check";
import FailIcon from "@mui/icons-material/Error";
import { PRIMARY, DARK_PRIMARY } from "@src/theme/colors";
import themes from "theme";
import { colord, extend } from "colord";
import a11yPlugin from "colord/plugins/a11y";
import mixPlugin from "colord/plugins/mix";
extend([a11yPlugin, mixPlugin]);
export interface IThemeColorPickerProps {
currentLight?: string;
currentDark?: string;
handleSave: ({ light, dark }: { light: string; dark: string }) => void;
}
export default function ThemeColorPicker({
currentLight = PRIMARY,
currentDark = DARK_PRIMARY,
handleSave,
}: IThemeColorPickerProps) {
const theme = useTheme();
const [light, setLight] = useState(currentLight);
const [dark, setDark] = useState(currentDark);
const lightTheme = themes.light({});
const darkTheme = themes.dark({});
return (
<>
<Grid
container
spacing={2}
sx={{
mt: 0,
"& .rcp": {
borderRadius: 1,
boxShadow: (theme) =>
`0 0 0 1px ${theme.palette.divider} inset !important`,
"& .rcp-fields-element-input": theme.typography.body2,
},
}}
>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" component="h3" gutterBottom>
Light theme
</Typography>
<ColorPicker
width={244}
height={140}
color={toColor("hex", light)}
onChange={(c) => () => setLight(c.hex)}
dark={theme.palette.mode === "dark"}
/>
<Stack
spacing={0}
sx={{ mt: 2, borderRadius: 1, overflow: "hidden" }}
>
<Swatch
backgroundColor={light}
textColor={lightTheme.palette.getContrastText(light)}
/>
<Swatch
backgroundColor={colord(lightTheme.palette.background.paper)
.mix(
light,
lightTheme.palette.action.selectedOpacity +
lightTheme.palette.action.focusOpacity
)
.alpha(1)
.toHslString()}
textColor={lightTheme.palette.text.primary}
/>
<Swatch
backgroundColor={colord(lightTheme.palette.background.default)
.mix(light, lightTheme.palette.action.hoverOpacity)
.alpha(1)
.toHslString()}
textColor={light}
/>
<Swatch
backgroundColor={lightTheme.palette.background.default}
textColor={light}
/>
<Swatch
backgroundColor={lightTheme.palette.background.paper}
textColor={light}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" component="h3" gutterBottom>
Dark theme
</Typography>
<ColorPicker
width={244}
height={140}
color={toColor("hex", dark)}
onChange={(c: any) => setDark(c.hex)}
dark={theme.palette.mode === "dark"}
/>
<Stack
spacing={0}
sx={{ mt: 2, borderRadius: 1, overflow: "hidden" }}
>
<Swatch
backgroundColor={dark}
textColor={darkTheme.palette.getContrastText(dark)}
/>
<Swatch
backgroundColor={colord(darkTheme.palette.background.paper)
.mix("#fff", 0.16)
.mix(
dark,
darkTheme.palette.action.selectedOpacity +
darkTheme.palette.action.focusOpacity
)
.alpha(1)
.toHslString()}
textColor={darkTheme.palette.text.primary}
/>
<Swatch
backgroundColor={colord(darkTheme.palette.background.paper)
.mix("#fff", 0.16)
.mix(dark, darkTheme.palette.action.hoverOpacity)
.alpha(1)
.toHslString()}
textColor={dark}
/>
<Swatch
backgroundColor={colord(darkTheme.palette.background.paper)
.mix("#fff", 0.16)
.alpha(1)
.toHslString()}
textColor={dark}
/>
<Swatch
backgroundColor={darkTheme.palette.background.default}
textColor={dark}
/>
</Stack>
</Grid>
</Grid>
<Box
sx={{
mt: 2,
position: "sticky",
bottom: (theme) => theme.spacing(2),
borderRadius: 1,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
bgcolor: (theme) =>
theme.palette.mode === "dark"
? "#1C1E21"
: theme.palette.background.paper,
boxShadow: (theme) =>
`0 0 0 16px ${
theme.palette.mode === "dark"
? "#1C1E21"
: theme.palette.background.paper
}`,
paddingBottom: "env(safe-area-inset-bottom)",
}}
>
<Button
variant="contained"
color="primary"
size="large"
fullWidth
onClick={() => handleSave({ light, dark })}
disabled={light === currentLight && dark === currentDark}
>
Save
</Button>
</Box>
</>
);
}
function Swatch({ backgroundColor, textColor }: Record<string, string>) {
if (colord(textColor).alpha() < 1)
textColor = colord(backgroundColor)
.mix(textColor, colord(textColor).alpha())
.alpha(1)
.toHslString();
const contrast = colord(backgroundColor).contrast(textColor);
const AAA = colord(backgroundColor).isReadable(textColor, { level: "AAA" });
const AA = colord(backgroundColor).isReadable(textColor, { level: "AA" });
return (
<Box
sx={{
bgcolor: backgroundColor,
color: textColor,
p: 1,
pr: 1.5,
typography: "button",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontVariantNumeric: "tabular-nums",
textAlign: "right",
}}
>
<Box
component="span"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
height: 24,
pl: 1,
pr: 0.75,
borderRadius: 0.5,
bgcolor: AAA || AA ? "transparent" : "error.main",
color: AAA || AA ? "inherit" : "error.contrastText",
"& svg": {
fontSize: "1.125rem",
ml: -0.5,
mr: 0.5,
},
}}
>
{AAA || AA ? <PassIcon /> : <FailIcon />}
{AAA ? "AAA" : AA ? "AA" : "FAIL"}
</Box>
{contrast.toFixed(2)}
</Box>
);
}

View File

@@ -0,0 +1,35 @@
import { IUserSettingsChildProps } from "@src/pages/Settings/UserSettings";
import { Link } from "react-router-dom";
import { Grid, Avatar, Typography, Button } from "@mui/material";
import ROUTES from "@src/constants/routes";
export default function Account({ settings }: IUserSettingsChildProps) {
return (
<Grid container spacing={2} alignItems="center">
<Grid item>
<Avatar src={settings.user.photoURL} />
</Grid>
<Grid item xs>
<Typography variant="body1" style={{ userSelect: "all" }}>
{settings.user.displayName}
</Typography>
<Typography
variant="body2"
color="textSecondary"
style={{ userSelect: "all" }}
>
{settings.user.email}
</Typography>
</Grid>
<Grid item>
<Button component={Link} to={ROUTES.signOut}>
Sign out
</Button>
</Grid>
</Grid>
);
}

View File

@@ -0,0 +1,63 @@
import { lazy, Suspense, useState } from "react";
import { IUserSettingsChildProps } from "@src/pages/Settings/UserSettings";
import { merge, unset } from "lodash-es";
import { FormControlLabel, Checkbox, Collapse } from "@mui/material";
import Loading from "@src/components/Loading";
// prettier-ignore
const ThemeColorPicker = lazy(() => import("@src/components/Settings/ThemeColorPicker") /* webpackChunkName: "ThemeColorPicker" */);
export default function Personalization({
settings,
updateSettings,
}: IUserSettingsChildProps) {
const [customizedThemeColor, setCustomizedThemeColor] = useState(
Boolean(
settings.theme?.light?.palette?.primary?.main ||
settings.theme?.dark?.palette?.primary?.main
)
);
const handleSave = ({ light, dark }: { light: string; dark: string }) => {
updateSettings({
theme: merge(settings.theme, {
light: { palette: { primary: { main: light } } },
dark: { palette: { primary: { main: dark } } },
}),
});
};
return (
<>
<FormControlLabel
control={
<Checkbox
checked={customizedThemeColor}
onChange={(e) => {
setCustomizedThemeColor(e.target.checked);
if (!e.target.checked) {
const newTheme = settings.theme;
unset(newTheme, "light.palette.primary.main");
unset(newTheme, "dark.palette.primary.main");
updateSettings({ theme: newTheme });
}
}}
/>
}
label="Customize theme colors"
style={{ marginLeft: -11, marginBottom: -10, marginTop: -10 }}
/>
<Collapse in={customizedThemeColor} style={{ marginTop: 0 }}>
<Suspense fallback={<Loading />}>
<ThemeColorPicker
currentLight={settings.theme?.light?.palette?.primary?.main}
currentDark={settings.theme?.dark?.palette?.primary?.main}
handleSave={handleSave}
/>
</Suspense>
</Collapse>
</>
);
}

View File

@@ -0,0 +1,76 @@
import { useAtom } from "jotai";
import { merge } from "lodash-es";
import { IUserSettingsChildProps } from "@src/pages/Settings/UserSettings";
import {
FormControl,
RadioGroup,
FormControlLabel,
Radio,
Divider,
Checkbox,
} from "@mui/material";
import {
globalScope,
themeAtom,
themeOverriddenAtom,
} from "@src/atoms/globalScope";
export default function Theme({
settings,
updateSettings,
}: IUserSettingsChildProps) {
const [theme, setTheme] = useAtom(themeAtom, globalScope);
const [themeOverridden, setThemeOverridden] = useAtom(
themeOverriddenAtom,
globalScope
);
return (
<>
<FormControl component="fieldset" variant="standard" sx={{ my: -10 / 8 }}>
<legend style={{ fontSize: 0 }}>Theme</legend>
<RadioGroup
value={themeOverridden ? theme : "system"}
onChange={(e) => {
if (e.target.value === "system") {
setThemeOverridden(false);
} else {
setTheme(e.target.value as typeof theme);
setThemeOverridden(true);
}
}}
>
<FormControlLabel
control={<Radio />}
value="system"
label="Match system theme"
/>
<FormControlLabel control={<Radio />} value="light" label="Light" />
<FormControlLabel control={<Radio />} value="dark" label="Dark" />
</RadioGroup>
</FormControl>
<Divider />
<FormControlLabel
control={
<Checkbox
checked={Boolean(settings.theme?.dark?.palette?.darker)}
onChange={(e) => {
updateSettings({
theme: merge(settings.theme, {
dark: { palette: { darker: e.target.checked } },
}),
});
}}
/>
}
label="Darker dark theme"
style={{ marginLeft: -11, marginBottom: -10, marginTop: 13 }}
/>
</>
);
}

View File

@@ -22,7 +22,7 @@ import ThumbDownOffIcon from "@mui/icons-material/ThumbDownOffAlt";
import { analytics } from "@src/analytics";
import { globalScope } from "@src/atoms/globalScope";
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
import { routes } from "@src/constants/routes";
import { ROUTES } from "@src/constants/routes";
import { SETTINGS } from "config/dbPaths";
export default {
@@ -93,7 +93,7 @@ function StepFinish() {
color="primary"
size="large"
component={Link}
to={routes.auth}
to={ROUTES.auth}
>
Sign in to your Rowy project
</Button>

View File

@@ -1,26 +0,0 @@
export enum routes {
home = "/",
auth = "/auth",
impersonatorAuth = "/impersonatorAuth",
jwtAuth = "/jwtAuth",
signOut = "/signOut",
signUp = "/signUp",
authSetup = "/authSetup",
setup = "/setup",
pageNotFound = "/404",
table = "/table",
tableWithId = "/table/:id",
tableGroup = "/tableGroup",
tableGroupWithId = "/tableGroup/:id",
settings = "/settings",
userSettings = "/settings/user",
projectSettings = "/settings/project",
userManagement = "/settings/userManagement",
rowyRunTest = "/rrTest",
}
export default routes;

57
src/constants/routes.tsx Normal file
View File

@@ -0,0 +1,57 @@
import Logo from "@src/assets/Logo";
import { GrowProps } from "@mui/material";
export enum ROUTES {
home = "/",
auth = "/auth",
impersonatorAuth = "/impersonatorAuth",
jwtAuth = "/jwtAuth",
signOut = "/signOut",
signUp = "/signUp",
authSetup = "/authSetup",
setup = "/setup",
pageNotFound = "/404",
table = "/table",
tableWithId = "/table/:id",
tableGroup = "/tableGroup",
tableGroupWithId = "/tableGroup/:id",
settings = "/settings",
userSettings = "/settings/user",
projectSettings = "/settings/project",
userManagement = "/settings/userManagement",
rowyRunTest = "/rrTest",
}
export default ROUTES;
export const ROUTE_TITLES = {
[ROUTES.home]: {
title: "Home",
titleComponent: (open, pinned) =>
!(open && pinned) && (
<Logo
style={{
display: "block",
margin: "0 auto",
}}
/>
),
},
[ROUTES.settings]: "Settings",
[ROUTES.userSettings]: "Settings",
[ROUTES.projectSettings]: "Project Settings",
[ROUTES.userManagement]: "User Management",
[ROUTES.rowyRunTest]: "Rowy Run Test",
} as Record<
ROUTES,
| string
| {
title: string;
titleComponent: (open: boolean, pinned: boolean) => React.ReactNode;
titleTransitionProps?: Partial<GrowProps>;
}
>;

View File

@@ -0,0 +1,31 @@
import { useEffect } from "react";
/**
* Sets the document/tab title and resets when the page is changed
* @param projectId - Project ID displayed in the title
* @param title - Title to be displayed
*/
export function useDocumentTitle(projectId: string, title?: string) {
useEffect(() => {
document.title = [
title,
projectId,
"Rowy",
window.location.hostname === "localhost" ? "localhost" : "",
]
.filter((x) => x)
.join(" • ");
return () => {
document.title = [
projectId,
"Rowy",
window.location.hostname === "localhost" ? "localhost" : "",
]
.filter((x) => x)
.join(" • ");
};
}, [title, projectId]);
}
export default useDocumentTitle;

View File

@@ -7,19 +7,23 @@ import {
DocumentData,
onSnapshot,
FirestoreError,
setDoc,
} from "firebase/firestore";
import { useErrorHandler } from "react-error-boundary";
import { globalScope } from "@src/atoms/globalScope";
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
/** Options for {@link useFirestoreDocWithAtom} */
interface IUseFirestoreDocWithAtomOptions {
interface IUseFirestoreDocWithAtomOptions<T> {
/** Additional path segments appended to the path. If any are undefined, the listener isnt created at all. */
pathSegments?: Array<string | undefined>;
/** Called when an error occurs. Make sure to wrap in useCallback! */
/** Called when an error occurs. Make sure to wrap in useCallback! If not provided, errors trigger the nearest ErrorBoundary. */
onError?: (error: FirestoreError) => void;
/** Optionally disable Suspense */
disableSuspense?: boolean;
/** Optionally create the document if it doesnt exist with the following data */
createIfNonExistent?: T;
}
/**
@@ -33,17 +37,19 @@ interface IUseFirestoreDocWithAtomOptions {
* @param path - Document path. If falsy, the listener isnt created at all.
* @param options - {@link IUseFirestoreDocWithAtomOptions}
*/
export function useFirestoreDocWithAtom(
export function useFirestoreDocWithAtom<T = DocumentData>(
dataAtom: PrimitiveAtom<DocumentData>,
dataScope: Scope | undefined,
path: string | undefined,
options?: IUseFirestoreDocWithAtomOptions
options?: IUseFirestoreDocWithAtomOptions<T>
) {
const [firebaseDb] = useAtom(firebaseDbAtom, globalScope);
const setDataAtom = useUpdateAtom(dataAtom, dataScope);
const handleError = useErrorHandler();
// Destructure options so they can be used as useEffect dependencies
const { pathSegments, onError, disableSuspense } = options || {};
const { pathSegments, onError, disableSuspense, createIfNonExistent } =
options || {};
useEffect(() => {
if (!path || (Array.isArray(pathSegments) && pathSegments.some((x) => !x)))
@@ -60,19 +66,39 @@ export function useFirestoreDocWithAtom(
const unsubscribe = onSnapshot(
doc(firebaseDb, path, ...((pathSegments as string[]) || [])),
(doc) => {
setDataAtom(doc.data() || {});
try {
if (!doc.exists() && !!createIfNonExistent) {
setDoc(doc.ref, createIfNonExistent);
setDataAtom(createIfNonExistent);
} else {
setDataAtom(doc.data() || {});
}
} catch (error) {
if (onError) onError(error as FirestoreError);
else handleError(error);
}
suspended = false;
},
(error) => {
if (suspended) setDataAtom({});
if (onError) onError(error);
else handleError(error);
}
);
return () => {
unsubscribe();
};
}, [firebaseDb, path, pathSegments, onError, setDataAtom, disableSuspense]);
}, [
firebaseDb,
path,
pathSegments,
onError,
setDataAtom,
disableSuspense,
createIfNonExistent,
handleError,
]);
}
export default useFirestoreDocWithAtom;

View File

@@ -0,0 +1,92 @@
import { useState, useCallback, useEffect } from "react";
import { useAtom } from "jotai";
import { differenceInDays } from "date-fns";
import { compare } from "compare-versions";
import {
globalScope,
rowyRunAtom,
rowyRunLatestUpdateAtom,
} from "@src/atoms/globalScope";
import meta from "@root/package.json";
import { EXTERNAL_LINKS } from "@src/constants/externalLinks";
import { runRoutes } from "@src/constants/runRoutes";
// https://docs.github.com/en/rest/reference/repos#get-the-latest-release
const UPDATE_ENDPOINTS = {
rowy: meta.repository.url
.replace("github.com", "api.github.com/repos")
.replace(/.git$/, "/releases/latest"),
rowyRun:
EXTERNAL_LINKS.rowyRunGitHub.replace("github.com", "api.github.com/repos") +
"/releases/latest",
};
/**
* Get the latest version of Rowy and Rowy Run from GitHub releases,
* and the currently deployed versions
* @returns [latestUpdate, checkForUpdates, loading]
*/
export default function useUpdateCheck() {
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
// Store latest release from GitHub
const [latestUpdate, setLatestUpdate] = useAtom(
rowyRunLatestUpdateAtom,
globalScope
);
const [loading, setLoading] = useState(false);
// Check for updates using latest releases from GitHub
const checkForUpdates = useCallback(async () => {
setLoading(true);
const newState = {
lastChecked: new Date().toISOString(),
rowy: null,
rowyRun: null,
deployedRowy: meta.version,
deployedRowyRun: "",
};
// Make all requests simultaneously
const [resRowy, resRowyRun, deployedRowyRun] = await Promise.all([
fetch(UPDATE_ENDPOINTS.rowy, {
headers: { Accept: "application/vnd.github.v3+json" },
}).then((r) => r.json()),
fetch(UPDATE_ENDPOINTS.rowyRun, {
headers: { Accept: "application/vnd.github.v3+json" },
}).then((r) => r.json()),
rowyRun({ route: runRoutes.version }),
]);
// Only store the latest release
if (compare(resRowy.tag_name, meta.version, ">")) newState.rowy = resRowy;
if (
deployedRowyRun &&
compare(resRowyRun.tag_name, deployedRowyRun.version, ">")
)
newState.rowyRun = resRowyRun;
// Save deployed version
newState.deployedRowyRun = deployedRowyRun?.version ?? "";
setLatestUpdate(newState);
setLoading(false);
}, [setLoading, setLatestUpdate, rowyRun]);
// Check for new updates on page load if last check was more than 7 days ago
// or if deployed version has changed
useEffect(() => {
if (loading) return;
if (
!latestUpdate.lastChecked ||
differenceInDays(new Date(), new Date(latestUpdate.lastChecked)) > 7 ||
latestUpdate.deployedRowy !== meta.version
)
checkForUpdates();
}, [latestUpdate, loading, checkForUpdates]);
return [latestUpdate, checkForUpdates, loading] as const;
}

View File

@@ -0,0 +1,203 @@
import { useAtom } from "jotai";
import { find, groupBy } from "lodash-es";
import {
Drawer,
DrawerProps,
Stack,
IconButton,
List,
ListItemIcon,
ListItemText,
Divider,
} from "@mui/material";
import HomeIcon from "@mui/icons-material/HomeOutlined";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import ProjectSettingsIcon from "@mui/icons-material/BuildCircleOutlined";
import UserManagementIcon from "@mui/icons-material/AccountCircleOutlined";
import CloseIcon from "@mui/icons-material/MenuOpen";
import PinIcon from "@mui/icons-material/PushPinOutlined";
import UnpinIcon from "@mui/icons-material/PushPin";
import { APP_BAR_HEIGHT } from ".";
import Logo from "@src/assets/Logo";
import NavItem from "./NavItem";
import NavTableSection from "./NavTableSection";
import UpdateCheckBadge from "./UpdateCheckBadge";
import {
globalScope,
userRolesAtom,
userSettingsAtom,
tablesAtom,
TableSettings,
} from "@src/atoms/globalScope";
import { ROUTES } from "@src/constants/routes";
export const NAV_DRAWER_WIDTH = 256;
export interface INavDrawerProps extends DrawerProps {
currentSection?: string;
onClose: NonNullable<DrawerProps["onClose"]>;
pinned: boolean;
setPinned: React.Dispatch<React.SetStateAction<boolean>>;
canPin: boolean;
}
export default function NavDrawer({
open,
pinned,
setPinned,
canPin,
...props
}: INavDrawerProps) {
const [tables] = useAtom(tablesAtom, globalScope);
const [userSettings] = useAtom(userSettingsAtom, globalScope);
const [userRoles] = useAtom(userRolesAtom, globalScope);
const favorites = Array.isArray(userSettings.favoriteTables)
? userSettings.favoriteTables
: [];
const sections = {
Favorites: favorites
.map((id) => find(tables, { id }))
.filter((x) => x !== undefined) as TableSettings[],
...groupBy(tables, "section"),
};
const closeDrawer = (e: {}) => props.onClose(e, "escapeKeyDown");
return (
<Drawer
open={open}
{...props}
variant={pinned ? "persistent" : "temporary"}
anchor="left"
sx={{
width: open ? NAV_DRAWER_WIDTH : 0,
transition: (theme) =>
theme.transitions.create("width", {
easing: pinned
? theme.transitions.easing.easeOut
: theme.transitions.easing.sharp,
duration: pinned
? theme.transitions.duration.enteringScreen
: theme.transitions.duration.leavingScreen,
}),
flexShrink: 0,
"& .MuiDrawer-paper": {
minWidth: NAV_DRAWER_WIDTH,
bgcolor: pinned ? "background.default" : "background.paper",
},
}}
>
<Stack
direction="row"
alignItems="center"
sx={{
height: APP_BAR_HEIGHT,
flexShrink: 0,
px: 0.5,
position: "sticky",
top: 0,
zIndex: "appBar",
backgroundColor: "inherit",
backgroundImage: "inherit",
}}
>
<IconButton
aria-label="Close navigation drawer"
onClick={props.onClose as any}
size="large"
>
<CloseIcon />
</IconButton>
<Logo style={{ marginLeft: 1 }} />
{canPin && (
<IconButton
aria-label="Pin navigation drawer"
onClick={() => setPinned((p) => !p)}
aria-pressed={pinned}
size="large"
style={{ marginLeft: "auto" }}
>
{pinned ? <UnpinIcon /> : <PinIcon />}
</IconButton>
)}
</Stack>
<nav>
<List disablePadding>
<li>
<NavItem
to={ROUTES.home}
onClick={pinned ? undefined : closeDrawer}
>
<ListItemIcon>
<HomeIcon />
</ListItemIcon>
<ListItemText primary="Home" />
</NavItem>
</li>
<li>
<NavItem
to={ROUTES.userSettings}
onClick={pinned ? undefined : closeDrawer}
>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</NavItem>
</li>
{userRoles.includes("ADMIN") && (
<li>
<NavItem
to={ROUTES.projectSettings}
onClick={pinned ? undefined : closeDrawer}
>
<ListItemIcon>
<ProjectSettingsIcon />
</ListItemIcon>
<ListItemText primary="Project Settings" />
<UpdateCheckBadge sx={{ mr: 1.5 }} />
</NavItem>
</li>
)}
{userRoles.includes("ADMIN") && (
<li>
<NavItem
to={ROUTES.userManagement}
onClick={pinned ? undefined : closeDrawer}
>
<ListItemIcon>
<UserManagementIcon />
</ListItemIcon>
<ListItemText primary="User Management" />
</NavItem>
</li>
)}
<Divider variant="middle" sx={{ my: 1 }} />
{sections &&
Object.entries(sections)
.filter(([, tables]) => tables.length > 0)
.map(([section, tables]) => (
<NavTableSection
key={section}
section={section}
tables={tables}
closeDrawer={pinned ? undefined : closeDrawer}
/>
))}
</List>
</nav>
</Drawer>
);
}

View File

@@ -0,0 +1,21 @@
import { Link, useLocation } from "react-router-dom";
import { MenuItem, MenuItemProps } from "@mui/material";
export default function NavItem(props: MenuItemProps<typeof Link>) {
const { pathname } = useLocation();
return (
<MenuItem
component={Link}
selected={pathname === props.to}
{...props}
sx={{
...props.sx,
"&&::before": {
left: "auto",
right: 0,
},
}}
/>
);
}

View File

@@ -0,0 +1,78 @@
import { useState } from "react";
import { useLocation } from "react-router-dom";
import { List, ListItemText, Collapse } from "@mui/material";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import NavItem from "./NavItem";
import { TableSettings } from "@src/atoms/globalScope";
import { ROUTES } from "@src/constants/routes";
export interface INavDrawerItemProps {
open?: boolean;
section: string;
tables: TableSettings[];
currentSection?: string;
closeDrawer?: (e: {}) => void;
}
export default function NavDrawerItem({
open: openProp,
section,
tables,
currentSection,
closeDrawer,
}: INavDrawerItemProps) {
const { pathname } = useLocation();
const [open, setOpen] = useState(openProp || section === currentSection);
return (
<li>
<NavItem
{...({ component: "button" } as any)}
selected={!open && currentSection === section}
onClick={() => setOpen((o) => !o)}
>
<ListItemText primary={section} style={{ textAlign: "left" }} />
<ArrowDropDownIcon
sx={{
color: "action.active",
transform: open ? "rotate(180deg)" : "rotate(0)",
transition: (theme) => theme.transitions.create("transform"),
}}
/>
</NavItem>
<Collapse in={open}>
<List disablePadding>
{tables
.filter((x) => x)
.map((table) => {
const route =
table.tableType === "collectionGroup"
? `${ROUTES.tableGroup}/${table.id}`
: `${ROUTES.table}/${table.id.replace(/\//g, "~2F")}`;
return (
<li key={table.id}>
<NavItem
to={route}
selected={pathname.split("%2F")[0] === route}
onClick={closeDrawer}
sx={{
ml: 2,
width: (theme) =>
`calc(100% - ${theme.spacing(2 + 0.5)})`,
}}
>
<ListItemText primary={table.name} />
</NavItem>
</li>
);
})}
</List>
</Collapse>
</li>
);
}

View File

@@ -0,0 +1,22 @@
import { Badge, BadgeProps } from "@mui/material";
import useUpdateCheck from "@src/hooks/useUpdateCheck";
export default function UpdateCheckBadge(props: Partial<BadgeProps>) {
const [latestUpdate] = useUpdateCheck();
if (!latestUpdate.rowy && !latestUpdate.rowyRun) return <>{props.children}</>;
return (
<Badge
badgeContent=" "
color="error"
variant="dot"
aria-label="Update available"
{...props}
sx={{
"& .MuiBadge-badge": { bgcolor: "#f00" },
...props.sx,
}}
/>
);
}

View File

@@ -0,0 +1,182 @@
import { useState, useRef } from "react";
import { useAtom } from "jotai";
import { Link } from "react-router-dom";
import {
IconButton,
IconButtonProps,
Avatar,
Menu,
MenuItem,
ListItem,
ListItemAvatar,
ListItemText,
Typography,
ListItemSecondaryAction,
Divider,
Grow,
} from "@mui/material";
import AccountCircleIcon from "@mui/icons-material/AccountCircleOutlined";
import ArrowRightIcon from "@mui/icons-material/ArrowRight";
import {
globalScope,
projectIdAtom,
userSettingsAtom,
themeAtom,
themeOverriddenAtom,
} from "@src/atoms/globalScope";
import ROUTES from "@src/constants/routes";
export default function UserMenu(props: IconButtonProps) {
const anchorEl = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const [themeSubMenu, setThemeSubMenu] = useState<EventTarget | null>(null);
const [projectId] = useAtom(projectIdAtom, globalScope);
const [userSettings] = useAtom(userSettingsAtom, globalScope);
const [theme, setTheme] = useAtom(themeAtom, globalScope);
const [themeOverridden, setThemeOverridden] = useAtom(
themeOverriddenAtom,
globalScope
);
const displayName = userSettings.user?.displayName;
const avatarUrl = userSettings.user?.photoURL;
const email = userSettings.user?.email;
const avatar = avatarUrl ? (
<Avatar src={avatarUrl} />
) : (
<AccountCircleIcon color="secondary" />
);
const changeTheme = (option: "system" | "light" | "dark") => {
if (option === "system") {
setThemeOverridden(false);
} else {
setTheme(option);
setThemeOverridden(true);
}
setThemeSubMenu(null);
setOpen(false);
};
return (
<>
<Grow in>
<IconButton
aria-label="Open user menu"
aria-controls="user-menu"
aria-haspopup="true"
edge="end"
size="large"
{...props}
ref={anchorEl}
onClick={() => setOpen(true)}
sx={{ "& .MuiAvatar-root": { width: 24, height: 24 } }}
>
{avatar}
</IconButton>
</Grow>
<Menu
anchorEl={anchorEl.current}
id="user-menu"
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
open={open}
onClose={() => setOpen(false)}
sx={{ "& .MuiPaper-root": { minWidth: 160 } }}
>
<ListItem
sx={{
cursor: "default",
flexDirection: "column",
textAlign: "center",
pt: 1.5,
}}
>
<ListItemAvatar
sx={{
minWidth: 48,
"& > *": { width: 48, height: 48, fontSize: 48 },
}}
>
{avatar}
</ListItemAvatar>
<ListItemText
primary={displayName}
secondary={
<>
{email}
<br />
<Typography variant="caption">Project: {projectId}</Typography>
</>
}
primaryTypographyProps={{ variant: "subtitle1" }}
/>
</ListItem>
<Divider variant="middle" sx={{ mt: 0.5, mb: 0.5 }} />
<MenuItem onClick={(e) => setThemeSubMenu(e.target)}>
Theme
<ListItemSecondaryAction style={{ pointerEvents: "none" }}>
<ArrowRightIcon style={{ display: "block" }} />
</ListItemSecondaryAction>
</MenuItem>
{themeSubMenu && (
<Menu
anchorEl={themeSubMenu as any}
id="theme-sub-menu"
anchorOrigin={{ vertical: "top", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
open
onClose={() => setThemeSubMenu(null)}
sx={{ "& .MuiPaper-root": { mt: -0.5 } }}
>
<MenuItem
onClick={() => changeTheme("system")}
selected={!themeOverridden}
>
System
</MenuItem>
<MenuItem
onClick={() => changeTheme("light")}
selected={themeOverridden && theme === "light"}
>
Light
</MenuItem>
<MenuItem
onClick={() => changeTheme("dark")}
selected={themeOverridden && theme === "dark"}
>
Dark
</MenuItem>
</Menu>
)}
<MenuItem
component={Link}
to={ROUTES.userSettings}
onClick={() => setOpen(false)}
>
Settings
</MenuItem>
<Divider variant="middle" />
<MenuItem
component={Link}
to={ROUTES.signOut}
onClick={() => setOpen(false)}
>
Sign out
</MenuItem>
</Menu>
</>
);
}

View File

@@ -0,0 +1,194 @@
import React, { Suspense } from "react";
import { useAtom } from "jotai";
import { ErrorBoundary } from "react-error-boundary";
import { useLocation, Outlet } from "react-router-dom";
import {
useScrollTrigger,
useMediaQuery,
Stack,
AppBar,
Toolbar,
IconButton,
Box,
Typography,
Grow,
} from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import NavDrawer, { NAV_DRAWER_WIDTH } from "./NavDrawer";
import UserMenu from "./UserMenu";
import ErrorFallback, {
IErrorFallbackProps,
} from "@src/components/ErrorFallback";
import Loading from "@src/components/Loading";
import UpdateCheckBadge from "./UpdateCheckBadge";
import {
globalScope,
projectIdAtom,
userRolesAtom,
navOpenAtom,
navPinnedAtom,
} from "@src/atoms/globalScope";
import { ROUTE_TITLES } from "@src/constants/routes";
import { useDocumentTitle } from "@src/hooks/useDocumentTitle";
export const APP_BAR_HEIGHT = 56;
const StyledErrorFallback = (props: IErrorFallbackProps) => (
<ErrorFallback {...props} style={{ marginTop: -APP_BAR_HEIGHT }} />
);
export default function Navigation({ children }: React.PropsWithChildren<{}>) {
const [projectId] = useAtom(projectIdAtom, globalScope);
const [userRoles] = useAtom(userRolesAtom, globalScope);
const [open, setOpen] = useAtom(navOpenAtom, globalScope);
const [pinned, setPinned] = useAtom(navPinnedAtom, globalScope);
const trigger = useScrollTrigger({ disableHysteresis: true, threshold: 0 });
const canPin = !useMediaQuery((theme: any) => theme.breakpoints.down("lg"));
const { pathname } = useLocation();
const routeTitle = ROUTE_TITLES[pathname as keyof typeof ROUTE_TITLES] || "";
const title = typeof routeTitle === "string" ? routeTitle : routeTitle.title;
useDocumentTitle(projectId, title);
return (
<>
<AppBar
position="sticky"
color="inherit"
elevation={trigger ? 1 : 0}
sx={{
height: APP_BAR_HEIGHT, // Elevation 8
backgroundImage:
"linear-gradient(rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.09))",
"&::before": {
content: "''",
display: "block",
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
bgcolor: "background.default",
opacity: trigger ? 0 : 1,
transition: (theme) => theme.transitions.create("opacity"),
},
pl: canPin && pinned && open ? `${NAV_DRAWER_WIDTH}px` : 0,
transition: (theme) =>
theme.transitions.create("padding-left", {
easing:
canPin && pinned
? theme.transitions.easing.easeOut
: theme.transitions.easing.sharp,
duration:
canPin && pinned
? theme.transitions.duration.enteringScreen
: theme.transitions.duration.leavingScreen,
}),
}}
>
<Toolbar
sx={{
height: APP_BAR_HEIGHT,
minWidth: 0,
maxWidth: "none",
"&&": {
minHeight: APP_BAR_HEIGHT,
p: 0,
pl: (theme) =>
`max(env(safe-area-inset-left), ${theme.spacing(2)})`,
pr: (theme) =>
`max(env(safe-area-inset-right), ${theme.spacing(2)})`,
},
}}
>
{!(open && canPin && pinned) && (
<Grow in>
<IconButton
aria-label="Open navigation drawer"
onClick={() => setOpen(true)}
size="large"
edge="start"
>
{userRoles.includes("ADMIN") ? (
<UpdateCheckBadge>
<MenuIcon />
</UpdateCheckBadge>
) : (
<MenuIcon />
)}
</IconButton>
</Grow>
)}
<Grow
in
key={title}
{...(typeof routeTitle !== "string"
? routeTitle.titleTransitionProps
: undefined)}
>
<Box
sx={{
flex: 1,
overflowX: "auto",
userSelect: "none",
pl: open && canPin && pinned ? 48 / 8 : 0,
}}
>
{typeof routeTitle !== "string" ? (
routeTitle.titleComponent(open, canPin && pinned)
) : (
<Typography
variant="h6"
component="h1"
textAlign="center"
sx={{ typography: { sm: "h5" } }}
>
{title}
</Typography>
)}
</Box>
</Grow>
<UserMenu />
</Toolbar>
</AppBar>
<Stack direction="row">
<NavDrawer
open={open}
pinned={canPin && pinned}
setPinned={setPinned}
canPin={canPin}
onClose={() => setOpen(false)}
/>
<ErrorBoundary FallbackComponent={StyledErrorFallback}>
<Suspense
fallback={
<Loading fullScreen style={{ marginTop: -APP_BAR_HEIGHT }} />
}
>
<div
style={{
flexGrow: 1,
maxWidth:
canPin && pinned && open
? `calc(100% - ${NAV_DRAWER_WIDTH}px)`
: "100%",
}}
>
{children || <Outlet />}
</div>
</Suspense>
</ErrorBoundary>
</Stack>
</>
);
}

View File

@@ -5,7 +5,7 @@ import Loading from "@src/components/Loading";
import { globalScope } from "@src/atoms/globalScope";
import { currentUserAtom } from "@src/atoms/auth";
import routes from "constants/routes";
import { ROUTES } from "@src/constants/routes";
export interface IRequireAuthProps {
children: React.ReactElement;
@@ -24,7 +24,7 @@ export default function RequireAuth({ children }: IRequireAuthProps) {
if (currentUser === null)
return (
<Navigate
to={routes.auth + `?redirect=${encodeURIComponent(redirect)}`}
to={ROUTES.auth + `?redirect=${encodeURIComponent(redirect)}`}
replace
/>
);

View File

@@ -1,48 +1,50 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useEffect, useState } from "react";
import { useAtom } from "jotai";
import { useSnackbar } from "notistack";
import { getIdTokenResult, signOut } from "firebase/auth";
import {
getIdTokenResult,
signOut,
signInWithCustomToken,
} from "firebase/auth";
import { Typography, Button, TextField } from "@mui/material";
import AuthLayout from "@src/layouts/AuthLayout";
import FirebaseUi from "@src/components/FirebaseUi";
import { globalScope } from "@src/atoms/globalScope";
import { globalScope, rowyRunAtom } from "@src/atoms/globalScope";
import { firebaseAuthAtom } from "@src/sources/ProjectSourceFirebase";
import { runRoutes } from "@src/constants/runRoutes";
export default function ImpersonatorAuthPage() {
const [firebaseAuth] = useAtom(firebaseAuthAtom, globalScope);
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
const { enqueueSnackbar } = useSnackbar();
// TODO:
// const { rowyRun } = useProjectContext();
useEffect(() => {
//sign out user on initial load
// signOut();
}, []);
// sign out user on initial load
signOut(firebaseAuth);
}, [firebaseAuth]);
const [loading, setLoading] = useState(false);
const [adminUser, setAdminUser] = useState();
const [email, setEmail] = useState("");
const handleAuth = async (email: string) => {
// if (!rowyRun) return;
// setLoading(true);
// const resp = await rowyRun({
// route: runRoutes.impersonateUser,
// params: [email],
// });
// setLoading(false);
// if (resp.success) {
// enqueueSnackbar(resp.message, { variant: "success" });
// await auth.signInWithCustomToken(resp.token);
// window.location.href = "/";
// } else {
// enqueueSnackbar(resp.error.message, { variant: "error" });
// }
setLoading(true);
const resp = await rowyRun({
route: runRoutes.impersonateUser,
params: [email],
});
setLoading(false);
if (resp.success) {
enqueueSnackbar(resp.message, { variant: "success" });
await signInWithCustomToken(firebaseAuth, resp.token);
window.location.href = "/";
} else {
enqueueSnackbar(resp.error.message, { variant: "error" });
}
};
return (

View File

@@ -1,11 +1,14 @@
import { useState } from "react";
import { useAtom } from "jotai";
import { globalScope } from "@src/atoms/globalScope";
import { currentUserAtom, userRolesAtom } from "@src/atoms/auth";
import { publicSettingsAtom } from "@src/atoms/project";
// import StyledFirebaseAuth from "react-firebaseui/FirebaseAuth";
// import "firebase/compat/auth";
// import { GoogleAuthProvider } from "firebase/auth";
import {
globalScope,
currentUserAtom,
userRolesAtom,
userSettingsAtom,
publicSettingsAtom,
projectSettingsAtom,
rowyRunAtom,
} from "@src/atoms/globalScope";
import { firebaseAuthAtom } from "@src/sources/ProjectSourceFirebase";
import { Button } from "@mui/material";
import { useSnackbar } from "notistack";
@@ -16,12 +19,14 @@ import {
signInWithPopup,
signOut,
User,
getIdTokenResult,
} from "firebase/auth";
import { userSettingsAtom } from "@src/atoms/user";
import { runRoutes } from "@src/constants/runRoutes";
const provider = new GoogleAuthProvider();
function CurrentUser({ currentUser }: { currentUser: User }) {
console.log("currentUser", currentUser.uid);
// console.log("currentUser", currentUser.uid);
return <p>{currentUser?.email}</p>;
}
@@ -30,10 +35,13 @@ function JotaiTest() {
const [currentUser] = useAtom(currentUserAtom, globalScope);
const [userRoles] = useAtom(userRolesAtom, globalScope);
const [publicSettings] = useAtom(publicSettingsAtom, globalScope);
const [projectSettings] = useAtom(projectSettingsAtom, globalScope);
const [userSettings] = useAtom(userSettingsAtom, globalScope);
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
// console.log("publicSettings", publicSettings);
// console.log("userSettings", userSettings);
const [count, setCount] = useState(0);
const { enqueueSnackbar } = useSnackbar();
useFirestoreDocWithAtom(
@@ -67,21 +75,32 @@ function JotaiTest() {
Sign out
</Button>
<Button onClick={() => getIdTokenResult(currentUser!).then(console.log)}>
getIdTokenResult
</Button>
<Button
onClick={() =>
rowyRun({ route: runRoutes.version, localhost: true }).then(
console.log
)
}
>
rowyRun
</Button>
{currentUser === undefined && <p>Authenticating </p>}
{currentUser && <CurrentUser currentUser={currentUser} />}
<p>{JSON.stringify(userRoles)}</p>
<p>{JSON.stringify(publicSettings)}</p>
<p>{JSON.stringify(projectSettings)}</p>
<p>{JSON.stringify(userSettings)}</p>
{/* <StyledFirebaseAuth
uiConfig={{
signInFlow: "popup",
signInSuccessUrl: "/",
signInOptions: [GoogleAuthProvider.PROVIDER_ID],
}}
firebaseAuth={firebaseAuth}
/> */}
<div>
<Button onClick={() => setCount((c) => c + 1)}>
Increment: {count}
</Button>
</div>
</>
);
}

View File

@@ -6,37 +6,48 @@ import GoIcon from "@src/assets/icons/Go";
import HomeIcon from "@mui/icons-material/HomeOutlined";
import AuthLayout from "@src/layouts/AuthLayout";
import Navigation, { APP_BAR_HEIGHT } from "@src/layouts/Navigation";
import EmptyState from "@src/components/EmptyState";
import meta from "@root/package.json";
import routes from "@src/constants/routes";
import { globalScope } from "@src/atoms/globalScope";
import { currentUserAtom } from "@src/atoms/auth";
import { ROUTES } from "@src/constants/routes";
import { globalScope, currentUserAtom } from "@src/atoms/globalScope";
export default function NotFound() {
const [currentUser] = useAtom(currentUserAtom, globalScope);
if (currentUser)
return (
<Navigation>
<EmptyState
fullScreen
message="Page not found"
description={
<Button
variant="outlined"
sx={{ mt: 3 }}
component={Link}
to={ROUTES.home}
startIcon={<HomeIcon />}
>
Home
</Button>
}
style={{ marginTop: -APP_BAR_HEIGHT }}
/>
</Navigation>
);
return (
<AuthLayout title="Page not found" hideLinks={Boolean(currentUser)}>
{currentUser ? (
<Button
variant="outlined"
sx={{ mt: 3 }}
component={Link}
to={routes.home}
startIcon={<HomeIcon />}
>
Home
</Button>
) : (
<Button
variant="outlined"
sx={{ mt: 3 }}
href={meta.homepage}
endIcon={<GoIcon style={{ margin: "0 -0.33em" }} />}
>
{meta.homepage.split("//")[1].replace(/\//g, "")}
</Button>
)}
<Button
variant="outlined"
sx={{ mt: 3 }}
href={meta.homepage}
endIcon={<GoIcon style={{ margin: "0 -0.33em" }} />}
>
{meta.homepage.split("//")[1].replace(/\//g, "")}
</Button>
</AuthLayout>
);
}

View File

@@ -0,0 +1,106 @@
import { useEffect } from "react";
import { useAtom } from "jotai";
import { useSnackbar } from "notistack";
import { useDebouncedCallback } from "use-debounce";
import { Container, Stack } from "@mui/material";
import SettingsSection from "@src/components/Settings/SettingsSection";
import About from "@src/components/Settings/ProjectSettings/About";
import RowyRun from "@src/components/Settings/ProjectSettings/RowyRun";
import Authentication from "@src/components/Settings/ProjectSettings/Authentication";
import Customization from "@src/components/Settings/ProjectSettings/Customization";
import {
globalScope,
projectSettingsAtom,
publicSettingsAtom,
} from "@src/atoms/globalScope";
export interface IProjectSettingsChildProps {
settings: Record<string, any>;
updateSettings: (data: Record<string, any>) => void;
publicSettings: Record<string, any>;
updatePublicSettings: (data: Record<string, any>) => void;
}
export default function ProjectSettingsPage() {
const [projectSettings] = useAtom(projectSettingsAtom, globalScope);
const [publicSettings] = useAtom(publicSettingsAtom, globalScope);
const { enqueueSnackbar } = useSnackbar();
const updateProjectSettings = useDebouncedCallback(
(data: Record<string, any>) => {
// TODO:
// db
// .doc(path)
// .update(data)
// .then(() =>
enqueueSnackbar("Saved", { variant: "success" });
// )
},
1000
);
// When the component is to be unmounted, force update settings
useEffect(
() => () => {
updateProjectSettings.flush();
},
[updateProjectSettings]
);
const updatePublicSettings = useDebouncedCallback(
(data: Record<string, any>) => {
// TODO:
// db
// .doc(path)
// .update(data)
// .then(() =>
enqueueSnackbar("Saved", { variant: "success" });
// )
},
1000
);
// When the component is to be unmounted, force update settings
useEffect(
() => () => {
updatePublicSettings.flush();
},
[updatePublicSettings]
);
const childProps: IProjectSettingsChildProps = {
settings: projectSettings,
updateSettings: updateProjectSettings,
publicSettings,
updatePublicSettings,
};
const sections = [
{ title: "About", Component: About },
{ title: `Rowy Run`, Component: RowyRun, props: childProps },
{ title: "Authentication", Component: Authentication, props: childProps },
{ title: "Customization", Component: Customization, props: childProps },
];
return (
<Container maxWidth="sm" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
<Stack spacing={4}>
{sections.map(({ title, Component, props }, i) => (
<SettingsSection
key={title}
title={title}
transitionTimeout={(i + 1) * 100}
>
<Component {...(props as any)} />
</SettingsSection>
))}
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,85 @@
import { useEffect } from "react";
import { useAtom } from "jotai";
import { useSnackbar } from "notistack";
import { useDebouncedCallback } from "use-debounce";
import { Container, Stack, Fade } from "@mui/material";
import SettingsSkeleton from "@src/components/Settings/SettingsSkeleton";
import SettingsSection from "@src/components/Settings/SettingsSection";
import Account from "@src/components/Settings/UserSettings/Account";
import Theme from "@src/components/Settings/UserSettings/Theme";
import Personalization from "@src/components/Settings/UserSettings/Personalization";
import {
globalScope,
currentUserAtom,
userSettingsAtom,
} from "@src/atoms/globalScope";
export interface IUserSettingsChildProps {
settings: Record<string, any>;
updateSettings: (data: Record<string, any>) => void;
}
export default function UserSettingsPage() {
const [currentUser] = useAtom(currentUserAtom, globalScope);
const [userSettings] = useAtom(userSettingsAtom, globalScope);
const { enqueueSnackbar } = useSnackbar();
const updateSettings = useDebouncedCallback((data: Record<string, any>) => {
// TODO:
// db
// .doc(path)
// .update(data)
// .then(() =>
enqueueSnackbar("Saved", { variant: "success" });
// )
}, 1000);
// When the component is to be unmounted, force update settings
useEffect(
() => () => {
updateSettings.flush();
},
[updateSettings]
);
const childProps: IUserSettingsChildProps = {
settings: userSettings,
updateSettings,
};
const sections = [
{ title: "Account", Component: Account, props: childProps },
{ title: "Theme", Component: Theme, props: childProps },
{ title: "Personalization", Component: Personalization, props: childProps },
];
return (
<Container maxWidth="sm" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
{!currentUser ? (
<Fade in timeout={1000} style={{ transitionDelay: "1s" }} unmountOnExit>
<Stack spacing={4}>
{new Array(sections.length).fill(null).map((_, i) => (
<SettingsSkeleton key={i} />
))}
</Stack>
</Fade>
) : (
<Stack spacing={4}>
{sections.map(({ title, Component, props }, i) => (
<SettingsSection
key={title}
title={title}
transitionTimeout={(i + 1) * 100}
>
<Component {...(props as any)} />
</SettingsSection>
))}
</Stack>
)}
</Container>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { atom, useAtom } from "jotai";
import { useUpdateAtom } from "jotai/utils";
import { FirebaseOptions, initializeApp } from "firebase/app";
import { getAuth, connectAuthEmulator, getIdTokenResult } from "firebase/auth";
@@ -9,13 +10,18 @@ import {
enableMultiTabIndexedDbPersistence,
} from "firebase/firestore";
import { currentUserAtom, userRolesAtom } from "@src/atoms/auth";
import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom";
import { globalScope } from "@src/atoms/globalScope";
import { projectIdAtom, publicSettingsAtom } from "@src/atoms/project";
import { useUpdateAtom } from "jotai/utils";
import { userSettingsAtom, UserSettings } from "@src/atoms/user";
import {
globalScope,
projectIdAtom,
projectSettingsAtom,
publicSettingsAtom,
currentUserAtom,
userRolesAtom,
userSettingsAtom,
UserSettings,
} from "@src/atoms/globalScope";
import { SETTINGS, PUBLIC_SETTINGS, USERS } from "@src/config/dbPaths";
export const envConfig = {
apiKey: process.env.REACT_APP_FIREBASE_PROJECT_WEB_API_KEY,
@@ -30,15 +36,22 @@ const envConnectEmulators =
process.env.NODE_ENV === "test" ||
process.env.REACT_APP_FIREBASE_EMULATOR === "true";
// Store Firebase config here so it can be set programmatically.
// This lets us switch between Firebase projects.
// Then app, auth, db, storage need to be derived atoms.
/**
* Store Firebase config here so it can be set programmatically.
* This lets us switch between Firebase projects.
* Then app, auth, db, storage need to be derived atoms.
*/
export const firebaseConfigAtom = atom<FirebaseOptions>(envConfig);
/** Store Firebase app instance */
export const firebaseAppAtom = atom((get) =>
initializeApp(get(firebaseConfigAtom))
);
/**
* Store Firebase Auth instance for current app.
* Connects to emulators based on env vars.
*/
export const firebaseAuthAtom = atom((get) => {
const auth = getAuth(get(firebaseAppAtom));
if (envConnectEmulators && !(window as any).firebaseAuthEmulatorStarted) {
@@ -50,18 +63,27 @@ export const firebaseAuthAtom = atom((get) => {
return auth;
});
/**
* Store Firestore instance for current app.
* Connects to emulators based on env vars, or enables multi-tab indexed db persistence.
*/
export const firebaseDbAtom = atom((get) => {
const db = initializeFirestore(get(firebaseAppAtom), {
ignoreUndefinedProperties: true,
});
if (!(window as any).firebaseDbStarted) {
if (envConnectEmulators) connectFirestoreEmulator(db, "localhost", 8080);
if (envConnectEmulators) connectFirestoreEmulator(db, "localhost", 9299);
else enableMultiTabIndexedDbPersistence(db);
(window as any).firebaseDbStarted = true;
}
return db;
});
/**
* When rendered, connects to a Firebase project.
*
* Sets project ID, project settings, public settings, current user, user roles, and user settings.
*/
export default function ProjectSourceFirebase() {
// Set projectId from Firebase project
const [firebaseConfig] = useAtom(firebaseConfigAtom, globalScope);
@@ -97,19 +119,29 @@ export default function ProjectSourceFirebase() {
}, [firebaseAuth, setCurrentUser, setUserRoles]);
// Store public settings in atom
useFirestoreDocWithAtom(publicSettingsAtom, globalScope, PUBLIC_SETTINGS);
// Store public settings in atom when a user is signed in
useFirestoreDocWithAtom(
publicSettingsAtom,
projectSettingsAtom,
globalScope,
"_rowy_/publicSettings"
currentUser ? SETTINGS : undefined
);
// Store user settings in atom when a user is signed in
useFirestoreDocWithAtom(
userSettingsAtom,
globalScope,
`_rowy_/userManagement/users`,
{ pathSegments: [currentUser?.uid] }
);
useFirestoreDocWithAtom<UserSettings>(userSettingsAtom, globalScope, USERS, {
pathSegments: [currentUser?.uid],
createIfNonExistent: currentUser
? {
user: {
email: currentUser.email || "",
displayName: currentUser.displayName || undefined,
photoURL: currentUser.photoURL || undefined,
phoneNumber: currentUser.phoneNumber || undefined,
},
}
: undefined,
});
return null;
}

View File

@@ -1,4 +1,4 @@
import { customRender, signIn } from "./testUtils";
import { customRender, signInAsAdmin } from "./testUtils";
import { screen } from "@testing-library/react";
import App from "@src/App";
@@ -11,11 +11,11 @@ test("renders without crashing", async () => {
});
test("signs in", async () => {
const initialAtomValues = await signIn();
const initialAtomValues = await signInAsAdmin();
customRender(<App />, initialAtomValues);
expect(await screen.findByText(/Nav/i)).toBeInTheDocument();
expect(await screen.findByText(/Nav/i)).toBeInTheDocument();
// expect(await screen.findByText(/{"emulator":true}/i)).toBeInTheDocument();
expect(await screen.findByText(/{"emulator":true}/i)).toBeInTheDocument();
});

View File

@@ -31,10 +31,10 @@ export const customRender = (
);
/**
* Signs in with Google using foo(at)example.com.
* Signs in with Google as an admin: admin(at)example.com
* Returns `initialAtomValues`, which must be passed to `customRender`.
*/
export const signIn = async () => {
export const signInAsAdmin = async () => {
const app = initializeApp(envConfig);
const auth = getAuth(app);
connectAuthEmulator(auth, "http://localhost:9099", { disableWarnings: true });
@@ -42,10 +42,10 @@ export const signIn = async () => {
const userCredential = await signInWithCredential(
auth,
GoogleAuthProvider.credential(
'{"sub": "abc123", "email": "foo@example.com", "email_verified": true}'
'{"sub": "abc123", "email": "admin@example.com", "email_verified": true}'
)
);
expect(userCredential.user.email).toBe("foo@example.com");
expect(userCredential.user.email).toBe("admin@example.com");
const initialAtomValues = [
[firebaseAuthAtom, auth],

View File

@@ -25,5 +25,5 @@
"jsx": "react-jsx",
"baseUrl": "src"
},
"include": ["src", "types"]
"include": ["src"]
}

171
yarn.lock
View File

@@ -1719,7 +1719,7 @@
core-js-pure "^3.20.2"
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.17.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.17.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
version "7.17.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72"
integrity sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==
@@ -2770,6 +2770,15 @@
"@mui/utils" "^5.6.0"
prop-types "^15.7.2"
"@mui/private-theming@^5.6.2":
version "5.6.2"
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.6.2.tgz#c42da32f8b9481ba12885176c0168a355727c189"
integrity sha512-IbrSfFXfiZdyhRMC2bgGTFtb16RBQ5mccmjeh3MtAERWuepiCK7gkW5D9WhEsfTu6iez+TEjeUKSgmMHlsM2mg==
dependencies:
"@babel/runtime" "^7.17.2"
"@mui/utils" "^5.6.1"
prop-types "^15.7.2"
"@mui/styled-engine@^5.6.0":
version "5.6.0"
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.6.0.tgz#c7c34d2e319158559cef49b796457a4e6a4c58f7"
@@ -2779,6 +2788,29 @@
"@emotion/cache" "^11.7.1"
prop-types "^15.7.2"
"@mui/styles@^5.6.2":
version "5.6.2"
resolved "https://registry.yarnpkg.com/@mui/styles/-/styles-5.6.2.tgz#6338f49cb55ae0b5052ff8fddeedf40a45cec815"
integrity sha512-QxEP5BUJALljOm7GHi3FF5hUy41EvrYWy5DerVjwggi8g4j4RnFG6Ak+EKsfJhrLLZoBdOyvNSoBn3o3B3dCsA==
dependencies:
"@babel/runtime" "^7.17.2"
"@emotion/hash" "^0.8.0"
"@mui/private-theming" "^5.6.2"
"@mui/types" "^7.1.3"
"@mui/utils" "^5.6.1"
clsx "^1.1.1"
csstype "^3.0.11"
hoist-non-react-statics "^3.3.2"
jss "^10.8.2"
jss-plugin-camel-case "^10.8.2"
jss-plugin-default-unit "^10.8.2"
jss-plugin-global "^10.8.2"
jss-plugin-nested "^10.8.2"
jss-plugin-props-sort "^10.8.2"
jss-plugin-rule-value-function "^10.8.2"
jss-plugin-vendor-prefixer "^10.8.2"
prop-types "^15.7.2"
"@mui/system@^5.6.0":
version "5.6.0"
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.6.0.tgz#4d6db0db6a8daf90acd7fcaab3a353aa127987ce"
@@ -2809,6 +2841,17 @@
prop-types "^15.7.2"
react-is "^17.0.2"
"@mui/utils@^5.6.1":
version "5.6.1"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.6.1.tgz#4ab79a21bd481555d9a588f4b18061b3c28ea5db"
integrity sha512-CPrzrkiBusCZBLWu0Sg5MJvR3fKJyK3gKecLVX012LULyqg2U64Oz04BKhfkbtBrPBbSQxM+DWW9B1c9hmV9nQ==
dependencies:
"@babel/runtime" "^7.17.2"
"@types/prop-types" "^15.7.4"
"@types/react-is" "^16.7.1 || ^17.0.0"
prop-types "^15.7.2"
react-is "^17.0.2"
"@mui/x-date-pickers@5.0.0-alpha.0":
version "5.0.0-alpha.0"
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-5.0.0-alpha.0.tgz#a62ffbab453d3c2dcd4ec20bd4f3f6338ad2ed3f"
@@ -3577,7 +3620,7 @@
dependencies:
"@types/react" "*"
"@types/react-router-dom@^5.3.3":
"@types/react-router-dom@*", "@types/react-router-dom@^5.3.3":
version "5.3.3"
resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.3.tgz#e9d6b4a66fcdbd651a5f106c2656a30088cc1e83"
integrity sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==
@@ -3586,6 +3629,15 @@
"@types/react" "*"
"@types/react-router" "*"
"@types/react-router-hash-link@^2.4.5":
version "2.4.5"
resolved "https://registry.yarnpkg.com/@types/react-router-hash-link/-/react-router-hash-link-2.4.5.tgz#41dcb55279351fedc9062115bb35db921d1d69f6"
integrity sha512-YsiD8xCWtRBebzPqG6kXjDQCI35LCN9MhV/MbgYF8y0trOp7VSUNmSj8HdIGyH99WCfSOLZB2pIwUMN/IwIDQg==
dependencies:
"@types/history" "^4.7.11"
"@types/react" "*"
"@types/react-router-dom" "*"
"@types/react-router@*":
version "5.1.16"
resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.16.tgz#f3ba045fb96634e38b21531c482f9aeb37608a99"
@@ -4836,6 +4888,11 @@ commondir@^1.0.1:
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
compare-versions@^4.1.3:
version "4.1.3"
resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-4.1.3.tgz#8f7b8966aef7dc4282b45dfa6be98434fc18a1a4"
integrity sha512-WQfnbDcrYnGr55UwbxKiQKASnTtNnaAWVi8jZyy8NTpVAXWACSne8lMD1iaIo9AiU6mnuLvSVshCzewVuWxHUg==
compressible@~2.0.16:
version "2.0.18"
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
@@ -5114,6 +5171,14 @@ css-tree@^1.1.3:
mdn-data "2.0.14"
source-map "^0.6.1"
css-vendor@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/css-vendor/-/css-vendor-2.0.8.tgz#e47f91d3bd3117d49180a3c935e62e3d9f7f449d"
integrity sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==
dependencies:
"@babel/runtime" "^7.8.3"
is-in-browser "^1.0.2"
css-what@^3.2.1:
version "3.4.2"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4"
@@ -6720,6 +6785,11 @@ husky@>=7.0.4:
resolved "https://registry.yarnpkg.com/husky/-/husky-7.0.4.tgz#242048245dc49c8fb1bf0cc7cfb98dd722531535"
integrity sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==
hyphenate-style-name@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d"
integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -6943,6 +7013,11 @@ is-glob@^4.0.1, is-glob@^4.0.3:
dependencies:
is-extglob "^2.1.1"
is-in-browser@^1.0.2, is-in-browser@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835"
integrity sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=
is-module@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
@@ -7698,6 +7773,76 @@ jsonpointer@^5.0.0:
resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.0.tgz#f802669a524ec4805fa7389eadbc9921d5dc8072"
integrity sha512-PNYZIdMjVIvVgDSYKTT63Y+KZ6IZvGRNNWcxwD+GNnUz1MKPfv30J8ueCjdwcN0nDx2SlshgyB7Oy0epAzVRRg==
jss-plugin-camel-case@^10.8.2:
version "10.9.0"
resolved "https://registry.yarnpkg.com/jss-plugin-camel-case/-/jss-plugin-camel-case-10.9.0.tgz#4921b568b38d893f39736ee8c4c5f1c64670aaf7"
integrity sha512-UH6uPpnDk413/r/2Olmw4+y54yEF2lRIV8XIZyuYpgPYTITLlPOsq6XB9qeqv+75SQSg3KLocq5jUBXW8qWWww==
dependencies:
"@babel/runtime" "^7.3.1"
hyphenate-style-name "^1.0.3"
jss "10.9.0"
jss-plugin-default-unit@^10.8.2:
version "10.9.0"
resolved "https://registry.yarnpkg.com/jss-plugin-default-unit/-/jss-plugin-default-unit-10.9.0.tgz#bb23a48f075bc0ce852b4b4d3f7582bc002df991"
integrity sha512-7Ju4Q9wJ/MZPsxfu4T84mzdn7pLHWeqoGd/D8O3eDNNJ93Xc8PxnLmV8s8ZPNRYkLdxZqKtm1nPQ0BM4JRlq2w==
dependencies:
"@babel/runtime" "^7.3.1"
jss "10.9.0"
jss-plugin-global@^10.8.2:
version "10.9.0"
resolved "https://registry.yarnpkg.com/jss-plugin-global/-/jss-plugin-global-10.9.0.tgz#fc07a0086ac97aca174e37edb480b69277f3931f"
integrity sha512-4G8PHNJ0x6nwAFsEzcuVDiBlyMsj2y3VjmFAx/uHk/R/gzJV+yRHICjT4MKGGu1cJq2hfowFWCyrr/Gg37FbgQ==
dependencies:
"@babel/runtime" "^7.3.1"
jss "10.9.0"
jss-plugin-nested@^10.8.2:
version "10.9.0"
resolved "https://registry.yarnpkg.com/jss-plugin-nested/-/jss-plugin-nested-10.9.0.tgz#cc1c7d63ad542c3ccc6e2c66c8328c6b6b00f4b3"
integrity sha512-2UJnDrfCZpMYcpPYR16oZB7VAC6b/1QLsRiAutOt7wJaaqwCBvNsosLEu/fUyKNQNGdvg2PPJFDO5AX7dwxtoA==
dependencies:
"@babel/runtime" "^7.3.1"
jss "10.9.0"
tiny-warning "^1.0.2"
jss-plugin-props-sort@^10.8.2:
version "10.9.0"
resolved "https://registry.yarnpkg.com/jss-plugin-props-sort/-/jss-plugin-props-sort-10.9.0.tgz#30e9567ef9479043feb6e5e59db09b4de687c47d"
integrity sha512-7A76HI8bzwqrsMOJTWKx/uD5v+U8piLnp5bvru7g/3ZEQOu1+PjHvv7bFdNO3DwNPC9oM0a//KwIJsIcDCjDzw==
dependencies:
"@babel/runtime" "^7.3.1"
jss "10.9.0"
jss-plugin-rule-value-function@^10.8.2:
version "10.9.0"
resolved "https://registry.yarnpkg.com/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.9.0.tgz#379fd2732c0746fe45168011fe25544c1a295d67"
integrity sha512-IHJv6YrEf8pRzkY207cPmdbBstBaE+z8pazhPShfz0tZSDtRdQua5jjg6NMz3IbTasVx9FdnmptxPqSWL5tyJg==
dependencies:
"@babel/runtime" "^7.3.1"
jss "10.9.0"
tiny-warning "^1.0.2"
jss-plugin-vendor-prefixer@^10.8.2:
version "10.9.0"
resolved "https://registry.yarnpkg.com/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.9.0.tgz#aa9df98abfb3f75f7ed59a3ec50a5452461a206a"
integrity sha512-MbvsaXP7iiVdYVSEoi+blrW+AYnTDvHTW6I6zqi7JcwXdc6I9Kbm234nEblayhF38EftoenbM+5218pidmC5gA==
dependencies:
"@babel/runtime" "^7.3.1"
css-vendor "^2.0.8"
jss "10.9.0"
jss@10.9.0, jss@^10.8.2:
version "10.9.0"
resolved "https://registry.yarnpkg.com/jss/-/jss-10.9.0.tgz#7583ee2cdc904a83c872ba695d1baab4b59c141b"
integrity sha512-YpzpreB6kUunQBbrlArlsMpXYyndt9JATbt95tajx0t4MTJJcCJdd4hdNpHmOIDiUJrF/oX5wtVFrS3uofWfGw==
dependencies:
"@babel/runtime" "^7.3.1"
csstype "^3.0.2"
is-in-browser "^1.1.3"
tiny-warning "^1.0.2"
"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.2.1:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.2.2.tgz#6ab1e52c71dfc0c0707008a91729a9491fe9f76c"
@@ -9398,6 +9543,11 @@ react-app-polyfill@^3.0.0:
regenerator-runtime "^0.13.9"
whatwg-fetch "^3.6.2"
react-color-palette@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/react-color-palette/-/react-color-palette-6.2.0.tgz#aa3be88f6953d57502c00f4433692129ffbad3e7"
integrity sha512-9rIboaRJNoeF8aCI2f3J8wgMyhl74SnGmZLDjor3bKf0iDBhP2EBv0/jGmm0hrj6OackGCqtWl5ZvM89XUc3sg==
react-data-grid@7.0.0-beta.5:
version "7.0.0-beta.5"
resolved "https://registry.yarnpkg.com/react-data-grid/-/react-data-grid-7.0.0-beta.5.tgz#bc39ce45b7a7f42ebfb66840e0ec1c8619d60f10"
@@ -9504,6 +9654,13 @@ react-router-dom@^6.3.0:
history "^5.2.0"
react-router "6.3.0"
react-router-hash-link@^2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/react-router-hash-link/-/react-router-hash-link-2.4.3.tgz#570824d53d6c35ce94d73a46c8e98673a127bf08"
integrity sha512-NU7GWc265m92xh/aYD79Vr1W+zAIXDWp3L2YZOYP4rCqPnJ6LI6vh3+rKgkidtYijozHclaEQTAHaAaMWPVI4A==
dependencies:
prop-types "^15.7.2"
react-router@6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557"
@@ -10709,6 +10866,11 @@ thunky@^1.0.2:
resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==
tiny-warning@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
tmp@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14"
@@ -10974,6 +11136,11 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
use-debounce@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-7.0.1.tgz#380e6191cc13ad29f8e2149a12b5c37cc2891190"
integrity sha512-fOrzIw2wstbAJuv8PC9Vg4XgwyTLEOdq4y/Z3IhVl8DAE4svRcgyEUvrEXu+BMNgMoc3YND6qLT61kkgEKXh7Q==
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"