mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
add settings pages
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
.yarn
|
||||
emulators/
|
||||
|
||||
@@ -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}]}
|
||||
@@ -1 +1 @@
|
||||
{ "signIn": { "allowDuplicateEmails": false }, "usageMode": "DEFAULT" }
|
||||
{"signIn":{"allowDuplicateEmails":false},"usageMode":"DEFAULT"}
|
||||
@@ -9,4 +9,4 @@
|
||||
"version": "10.6.0",
|
||||
"path": "auth_export"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -21,7 +21,7 @@
|
||||
"port": 9099
|
||||
},
|
||||
"firestore": {
|
||||
"port": 8080
|
||||
"port": 9299
|
||||
},
|
||||
"storage": {
|
||||
"port": 9199
|
||||
|
||||
@@ -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",
|
||||
|
||||
35
src/App.tsx
35
src/App.tsx
@@ -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 />} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
127
src/atoms/rowyRun.ts
Normal 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
6
src/atoms/ui.ts
Normal 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);
|
||||
@@ -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
|
||||
|
||||
70
src/components/SectionHeading.tsx
Normal file
70
src/components/SectionHeading.tsx
Normal 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;
|
||||
14
src/components/SectionHeadingSkeleton.tsx
Normal file
14
src/components/SectionHeadingSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/components/Settings/ProjectSettings/About.tsx
Normal file
156
src/components/Settings/ProjectSettings/About.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
src/components/Settings/ProjectSettings/Authentication.tsx
Normal file
50
src/components/Settings/ProjectSettings/Authentication.tsx
Normal 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 it’s 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
src/components/Settings/ProjectSettings/Customization.tsx
Normal file
61
src/components/Settings/ProjectSettings/Customization.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
196
src/components/Settings/ProjectSettings/RowyRun.tsx
Normal file
196
src/components/Settings/ProjectSettings/RowyRun.tsx
Normal 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" }}
|
||||
/>
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
43
src/components/Settings/SettingsSection.tsx
Normal file
43
src/components/Settings/SettingsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/components/Settings/SettingsSkeleton.tsx
Normal file
59
src/components/Settings/SettingsSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
245
src/components/Settings/ThemeColorPicker.tsx
Normal file
245
src/components/Settings/ThemeColorPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/Settings/UserSettings/Account.tsx
Normal file
35
src/components/Settings/UserSettings/Account.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/components/Settings/UserSettings/Personalization.tsx
Normal file
63
src/components/Settings/UserSettings/Personalization.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
src/components/Settings/UserSettings/Theme.tsx
Normal file
76
src/components/Settings/UserSettings/Theme.tsx
Normal 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 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
57
src/constants/routes.tsx
Normal 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>;
|
||||
}
|
||||
>;
|
||||
31
src/hooks/useDocumentTitle.ts
Normal file
31
src/hooks/useDocumentTitle.ts
Normal 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;
|
||||
@@ -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 isn’t 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 doesn’t exist with the following data */
|
||||
createIfNonExistent?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,17 +37,19 @@ interface IUseFirestoreDocWithAtomOptions {
|
||||
* @param path - Document path. If falsy, the listener isn’t 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;
|
||||
|
||||
92
src/hooks/useUpdateCheck.ts
Normal file
92
src/hooks/useUpdateCheck.ts
Normal 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;
|
||||
}
|
||||
203
src/layouts/Navigation/NavDrawer.tsx
Normal file
203
src/layouts/Navigation/NavDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/layouts/Navigation/NavItem.tsx
Normal file
21
src/layouts/Navigation/NavItem.tsx
Normal 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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
78
src/layouts/Navigation/NavTableSection.tsx
Normal file
78
src/layouts/Navigation/NavTableSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/layouts/Navigation/UpdateCheckBadge.tsx
Normal file
22
src/layouts/Navigation/UpdateCheckBadge.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
182
src/layouts/Navigation/UserMenu.tsx
Normal file
182
src/layouts/Navigation/UserMenu.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
194
src/layouts/Navigation/index.tsx
Normal file
194
src/layouts/Navigation/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
106
src/pages/Settings/ProjectSettings.tsx
Normal file
106
src/pages/Settings/ProjectSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/pages/Settings/UserSettings.tsx
Normal file
85
src/pages/Settings/UserSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -25,5 +25,5 @@
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "src"
|
||||
},
|
||||
"include": ["src", "types"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
171
yarn.lock
171
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user