move ProjectSettings to own page, add MigrateToV2

This commit is contained in:
Sidney Alcantara
2021-09-02 23:39:53 +10:00
parent a838553682
commit d09427f1c1
36 changed files with 1048 additions and 609 deletions

View File

@@ -10,6 +10,9 @@ import PrivateRoute from "utils/PrivateRoute";
import ErrorBoundary from "components/ErrorBoundary";
import EmptyState from "components/EmptyState";
import Loading from "components/Loading";
import Navigation from "components/Navigation";
import Logo from "assets/Logo";
import MigrateToV2 from "components/Settings/MigrateToV2";
import { SnackProvider } from "contexts/SnackContext";
import ConfirmationProvider from "components/ConfirmationDialog/Provider";
@@ -41,6 +44,8 @@ const TablePage = lazy(() => import("./pages/Table" /* webpackChunkName: "TableP
const ProjectSettingsPage = lazy(() => import("./pages/Settings/ProjectSettings" /* webpackChunkName: "ProjectSettingsPage" */));
// prettier-ignore
const UserSettingsPage = lazy(() => import("./pages/Settings/UserSettings" /* webpackChunkName: "UserSettingsPage" */));
// prettier-ignore
const UserManagementPage = lazy(() => import("./pages/Settings/UserManagement" /* webpackChunkName: "UserManagementPage" */));
export default function App() {
return (
@@ -92,6 +97,7 @@ export default function App() {
routes.settings,
routes.projectSettings,
routes.userSettings,
routes.userManagement,
]}
render={() => (
<RowyContextProvider>
@@ -99,7 +105,17 @@ export default function App() {
<PrivateRoute
exact
path={routes.home}
render={() => <HomePage />}
render={() => (
<Navigation
title={
<div style={{ textAlign: "center" }}>
<Logo />
</div>
}
>
<HomePage />
</Navigation>
)}
/>
<PrivateRoute
path={routes.tableWithId}
@@ -120,14 +136,33 @@ export default function App() {
<PrivateRoute
exact
path={routes.projectSettings}
render={() => <ProjectSettingsPage />}
render={() => (
<Navigation title="Project Settings">
<ProjectSettingsPage />
</Navigation>
)}
/>
<PrivateRoute
exact
path={routes.userSettings}
render={() => <UserSettingsPage />}
render={() => (
<Navigation title="Settings">
<UserSettingsPage />
</Navigation>
)}
/>
<PrivateRoute
exact
path={routes.userManagement}
render={() => (
<Navigation title="User Management">
<UserManagementPage />
</Navigation>
)}
/>
</Switch>
<MigrateToV2 />
</RowyContextProvider>
)}
/>

View File

@@ -1,10 +0,0 @@
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon";
import { mdiWrenchOutline } from "@mdi/js";
export default function ProjectSettings(props: SvgIconProps) {
return (
<SvgIcon {...props}>
<path d={mdiWrenchOutline} />
</SvgIcon>
);
}

View File

@@ -9,8 +9,9 @@ import { Typography } from "@material-ui/core";
import { alpha } from "@material-ui/core/styles";
import Skeleton from "@material-ui/core/Skeleton";
import { auth, db } from "../../firebase";
import { defaultUiConfig, getSignInOptions } from "../../firebase/firebaseui";
import { auth, db } from "@src/firebase";
import { defaultUiConfig, getSignInOptions } from "@src/firebase/firebaseui";
import { PUBLIC_SETTINGS } from "config/dbPaths";
const useStyles = makeStyles((theme) =>
createStyles({
@@ -47,9 +48,10 @@ const useStyles = makeStyles((theme) =>
"& .firebaseui-card-content, & .firebaseui-card-footer": { padding: 0 },
"& .firebaseui-idp-list, & .firebaseui-tenant-list": { margin: 0 },
"& .firebaseui-idp-list>.firebaseui-list-item, & .firebaseui-tenant-list>.firebaseui-list-item": {
margin: 0,
},
"& .firebaseui-idp-list>.firebaseui-list-item, & .firebaseui-tenant-list>.firebaseui-list-item":
{
margin: 0,
},
"& .firebaseui-list-item + .firebaseui-list-item": {
paddingTop: theme.spacing(2),
},
@@ -135,9 +137,10 @@ const useStyles = makeStyles((theme) =>
...theme.typography.subtitle2,
color: theme.palette.text.secondary,
},
"& .mdl-textfield--floating-label.is-dirty .mdl-textfield__label, .mdl-textfield--floating-label.is-focused .mdl-textfield__label": {
color: theme.palette.text.primary,
},
"& .mdl-textfield--floating-label.is-dirty .mdl-textfield__label, .mdl-textfield--floating-label.is-focused .mdl-textfield__label":
{
color: theme.palette.text.primary,
},
"& .firebaseui-textfield.mdl-textfield .firebaseui-label:after": {
backgroundColor: theme.palette.primary.main,
},
@@ -186,7 +189,7 @@ export default function FirebaseUi(props: Partial<FirebaseUiProps>) {
Parameters<typeof getSignInOptions>[0] | undefined
>();
useEffect(() => {
db.doc("/_rowy_/publicSettings")
db.doc(PUBLIC_SETTINGS)
.get()
.then((doc) => {
const options = doc?.get("signInOptions");

View File

@@ -1,67 +0,0 @@
import useDoc from "hooks/useDoc";
import Modal from "components/Modal";
import { Box, CircularProgress, Typography } from "@material-ui/core";
import { IFormDialogProps } from "./Table/ColumnMenu/NewColumn";
import CheckCircleIcon from "@material-ui/icons/CheckCircle";
export interface IProjectSettings
extends Pick<IFormDialogProps, "handleClose"> {}
export default function BuilderInstaller({ handleClose }: IProjectSettings) {
const [settingsState] = useDoc({
path: "_rowy_/settings",
});
if (settingsState.loading) return null;
const complete =
settingsState.doc.buildStatus === "COMPLETE" ||
!!settingsState.doc.buildUrl;
const building = settingsState.doc.buildStatus === "BUILDING";
const waiting = !settingsState.doc.buildStatus && !settingsState.doc.buildUrl;
return (
<Modal
onClose={handleClose}
title="One Click Builder Installer"
maxWidth="sm"
children={
<Box display="flex" flexDirection="column">
<Typography variant="body2">
You will be redirected to Google Cloud Shell to deploy Function
Builder to Cloud Run.
</Typography>
<br />
<Box
display="flex"
justifyContent="center"
alignItems="center"
flexDirection="column"
>
{complete && (
<>
<CheckCircleIcon />
<Typography variant="overline">Deploy Complete</Typography>
</>
)}
{building && (
<>
<CircularProgress size={25} />
<Typography variant="overline">Deploying...</Typography>
</>
)}
{waiting && (
<>
<CircularProgress size={25} />
<Typography variant="overline">
Waiting for deploy...
</Typography>
</>
)}
</Box>
</Box>
}
/>
);
}

View File

@@ -13,8 +13,8 @@ const ConfirmationProvider: React.FC<IConfirmationProviderProps> = ({
const [state, setState] = useState<confirmationProps>();
const [open, setOpen] = useState(false);
const handleClose = () => {
setState(undefined);
setOpen(false);
setTimeout(() => setState(undefined), 300);
};
const requestConfirmation = (props: confirmationProps) => {
setState(props);

View File

@@ -2,7 +2,13 @@ import { Zoom, Stack, Typography } from "@material-ui/core";
export default function HomeWelcomePrompt() {
return (
<Zoom in style={{ transformOrigin: `${320 - 52}px ${320 - 52}px` }}>
<Zoom
in
style={{
transformOrigin: `${320 - 52}px ${320 - 52}px`,
transitionDelay: "3s",
}}
>
<Stack
justifyContent="center"
sx={{

View File

@@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { useLocation, Link } from "react-router-dom";
import {
Drawer,
@@ -13,7 +13,8 @@ import {
} from "@material-ui/core";
import HomeIcon from "@material-ui/icons/HomeOutlined";
import SettingsIcon from "@material-ui/icons/SettingsOutlined";
import ProjectSettingsIcon from "assets/icons/ProjectSettings";
import ProjectSettingsIcon from "@material-ui/icons/BuildCircleOutlined";
import UserManagementIcon from "@material-ui/icons/AccountCircleOutlined";
import CloseIcon from "assets/icons/Backburger";
import { APP_BAR_HEIGHT } from ".";
@@ -37,6 +38,7 @@ export default function NavDrawer({
...props
}: INavDrawerProps) {
const { userClaims, sections } = useRowyContext();
const { pathname } = useLocation();
const closeDrawer = (e: {}) => props.onClose(e, "escapeKeyDown");
@@ -65,7 +67,12 @@ export default function NavDrawer({
<nav>
<List disablePadding>
<li>
<MenuItem component={Link} to={routes.home} onClick={closeDrawer}>
<MenuItem
component={Link}
to={routes.home}
selected={pathname === routes.home}
onClick={closeDrawer}
>
<ListItemIcon>
<HomeIcon />
</ListItemIcon>
@@ -75,7 +82,8 @@ export default function NavDrawer({
<li>
<MenuItem
component={Link}
to={routes.settings}
to={routes.userSettings}
selected={pathname === routes.userSettings}
onClick={closeDrawer}
>
<ListItemIcon>
@@ -89,6 +97,7 @@ export default function NavDrawer({
<MenuItem
component={Link}
to={routes.projectSettings}
selected={pathname === routes.projectSettings}
onClick={closeDrawer}
>
<ListItemIcon>
@@ -98,6 +107,21 @@ export default function NavDrawer({
</MenuItem>
</li>
)}
{userClaims?.roles?.includes("ADMIN") && (
<li>
<MenuItem
component={Link}
to={routes.userManagement}
selected={pathname === routes.userManagement}
onClick={closeDrawer}
>
<ListItemIcon>
<UserManagementIcon />
</ListItemIcon>
<ListItemText primary="User Management" />
</MenuItem>
</li>
)}
<Divider variant="middle" sx={{ mt: 1, mb: 1 }} />

View File

@@ -1,131 +0,0 @@
import { useState, useEffect } from "react";
import createPersistedState from "use-persisted-state";
import { differenceInDays } from "date-fns";
import { makeStyles, createStyles } from "@material-ui/styles";
import {
MenuItem,
ListItemText,
ListItemSecondaryAction,
} from "@material-ui/core";
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import meta from "../../../package.json";
import WIKI_LINKS from "constants/wikiLinks";
const useLastCheckedUpdateState = createPersistedState(
"__ROWY__LAST_CHECKED_UPDATE"
);
export const useLatestUpdateState = createPersistedState(
"__ROWY__LATEST_UPDATE"
);
const useStyles = makeStyles((theme) =>
createStyles({
secondaryAction: { pointerEvents: "none" },
secondaryIcon: {
display: "block",
color: theme.palette.action.active,
},
})
);
export default function UpdateChecker() {
const classes = useStyles();
const [lastCheckedUpdate, setLastCheckedUpdate] = useLastCheckedUpdateState<
string
>();
const [latestUpdate, setLatestUpdate] = useLatestUpdateState<null | Record<
string,
any
>>(null);
const [checkState, setCheckState] = useState<null | "LOADING" | "NO_UPDATE">(
null
);
const checkForUpdate = async () => {
setCheckState("LOADING");
// https://docs.github.com/en/rest/reference/repos#get-the-latest-release
const endpoint = meta.repository.url
.replace("github.com", "api.github.com/repos")
.replace(/.git$/, "/releases/latest");
try {
const res = await fetch(endpoint, {
headers: {
Accept: "application/vnd.github.v3+json",
},
});
const json = await res.json();
if (json.tag_name > "v" + meta.version) {
setLatestUpdate(json);
setCheckState(null);
} else {
setCheckState("NO_UPDATE");
}
setLastCheckedUpdate(new Date().toISOString());
} catch (e) {
console.error(e);
setLatestUpdate(null);
setCheckState("NO_UPDATE");
}
};
// Check for new updates on page load, if last check was more than 7 days ago
useEffect(() => {
if (!lastCheckedUpdate) checkForUpdate();
else if (differenceInDays(new Date(), new Date(lastCheckedUpdate)) > 7)
checkForUpdate();
}, [lastCheckedUpdate]);
// Verify latest update is not installed yet
useEffect(() => {
if (latestUpdate?.tag_name <= "v" + meta.version) setLatestUpdate(null);
}, [latestUpdate, setLatestUpdate]);
if (checkState === "LOADING")
return <MenuItem disabled>Checking for updates</MenuItem>;
if (checkState === "NO_UPDATE")
return <MenuItem disabled>No updates available</MenuItem>;
if (latestUpdate === null)
return <MenuItem onClick={checkForUpdate}>Check for updates</MenuItem>;
return (
<>
<MenuItem
component="a"
href={latestUpdate?.html_url}
target="_blank"
rel="noopener"
>
<ListItemText
primary="Update available"
secondary={latestUpdate?.tag_name}
/>
<ListItemSecondaryAction className={classes.secondaryAction}>
<OpenInNewIcon className={classes.secondaryIcon} />
</ListItemSecondaryAction>
</MenuItem>
<MenuItem
component="a"
href={WIKI_LINKS.updating}
target="_blank"
rel="noopener"
>
<ListItemText secondary="How to update" />
<ListItemSecondaryAction className={classes.secondaryAction}>
<OpenInNewIcon
color="secondary"
fontSize="small"
className={classes.secondaryIcon}
/>
</ListItemSecondaryAction>
</MenuItem>
</>
);
}

View File

@@ -11,7 +11,6 @@ import {
ListItemAvatar,
ListItemText,
ListItemSecondaryAction,
Link as MuiLink,
Divider,
} from "@material-ui/core";
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
@@ -19,8 +18,6 @@ import ArrowRightIcon from "@material-ui/icons/ArrowRight";
import { useAppContext } from "contexts/AppContext";
import routes from "constants/routes";
import { projectId } from "@src/firebase";
import meta from "@root/package.json";
export default function UserMenu(props: IconButtonProps) {
const anchorEl = useRef<HTMLButtonElement>(null);
@@ -96,9 +93,27 @@ export default function UserMenu(props: IconButtonProps) {
onClose={() => setOpen(false)}
sx={{ "& .MuiPaper-root": { minWidth: 160 } }}
>
<ListItem style={{ cursor: "default" }}>
<ListItemAvatar>{avatar}</ListItemAvatar>
<ListItemText primary={displayName} secondary={email} />
<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}
primaryTypographyProps={{ variant: "subtitle1" }}
/>
</ListItem>
<Divider variant="middle" sx={{ mt: 0.5, mb: 0.5 }} />
@@ -150,47 +165,6 @@ export default function UserMenu(props: IconButtonProps) {
<MenuItem component={Link} to={routes.signOut}>
Sign out
</MenuItem>
<Divider variant="middle" />
<ListItem>
<ListItemText
primary={
<MuiLink
component="a"
href={meta.repository.url.replace(".git", "") + "/releases"}
target="_blank"
rel="noopener"
underline="hover"
color="inherit"
>
{meta.name} v{meta.version}
</MuiLink>
}
secondary={
<>
Project:{" "}
<MuiLink
component="a"
href={`https://console.firebase.google.com/project/${projectId}`}
target="_blank"
rel="noopener"
underline="hover"
color="inherit"
>
{projectId}
</MuiLink>
</>
}
primaryTypographyProps={{ variant: "caption", color: "inherit" }}
secondaryTypographyProps={{ variant: "caption", color: "inherit" }}
sx={{
userSelect: "none",
color: "text.disabled",
margin: 0,
}}
/>
</ListItem>
</Menu>
</>
);

View File

@@ -7,22 +7,36 @@ import {
IconButton,
Box,
Typography,
Fade,
} from "@material-ui/core";
import MenuIcon from "@material-ui/icons/Menu";
import NavDrawer from "./NavDrawer";
import UserMenu from "./UserMenu";
import { name } from "@root/package.json";
import { projectId } from "@src/firebase";
export const APP_BAR_HEIGHT = 56;
export interface INavigationProps {
children: ReactNode;
title?: ReactNode;
currentSection?: string;
currentTable?: string;
}
export default function Navigation({ children, title }: INavigationProps) {
export default function Navigation({
children,
title,
currentSection,
currentTable,
}: INavigationProps) {
const [open, setOpen] = useState(false);
if (typeof title === "string")
document.title = `${title} | ${projectId} | ${name}`;
const trigger = useScrollTrigger({ disableHysteresis: true, threshold: 0 });
return (
@@ -30,8 +44,7 @@ export default function Navigation({ children, title }: INavigationProps) {
<AppBar
position="sticky"
color="inherit"
elevation={0}
className={trigger ? "scrolled" : ""}
elevation={trigger ? 1 : 0}
sx={{
height: APP_BAR_HEIGHT, // Elevation 8
backgroundImage:
@@ -47,13 +60,9 @@ export default function Navigation({ children, title }: INavigationProps) {
left: 0,
bgcolor: "background.default",
opacity: trigger ? 0 : 1,
transition: (theme) => theme.transitions.create("opacity"),
},
"&:hover, &.scrolled": {
boxShadow: 1,
"&::before": { opacity: 0 },
},
}}
>
<Toolbar
@@ -78,9 +87,9 @@ export default function Navigation({ children, title }: INavigationProps) {
<MenuIcon />
</IconButton>
<Box sx={{ flex: 1, ml: 20 / 8, mr: 20 / 8, userSelect: "none" }}>
<Box sx={{ flex: 1, userSelect: "none" }}>
{typeof title === "string" ? (
<Typography variant="h6" component="h1">
<Typography variant="h6" component="h1" textAlign="center">
{title}
</Typography>
) : (
@@ -93,7 +102,12 @@ export default function Navigation({ children, title }: INavigationProps) {
</Toolbar>
</AppBar>
<NavDrawer open={open} onClose={() => setOpen(false)} />
<NavDrawer
open={open}
onClose={() => setOpen(false)}
currentSection={currentSection}
currentTable={currentTable}
/>
{children}
</>

View File

@@ -1,74 +0,0 @@
import { FieldType } from "@antlerengineering/form-builder";
import _startCase from "lodash/startCase";
import { authOptions } from "firebase/firebaseui";
import { Link } from "@material-ui/core";
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import WIKI_LINKS from "constants/wikiLinks";
import { name } from "@root/package.json";
export const projectSettingsForm = [
{
type: FieldType.contentHeader,
name: "_contentHeading_signInOptions",
label: "Authentication",
},
{
type: FieldType.multiSelect,
name: "signInOptions",
label: "Sign-In Options",
options: Object.keys(authOptions).map((option) => ({
value: option,
label: _startCase(option).replace("Github", "GitHub"),
})),
defaultValue: ["google"],
required: true,
assistiveText: (
<>
Before enabling a new sign-in option, make sure its configured in your
Firebase project.
<br />
<Link
href={`https://github.com/firebase/firebaseui-web#configuring-sign-in-providers`}
target="_blank"
rel="noopener"
>
How to configure sign-in options
<OpenInNewIcon
aria-label="Open in new tab"
fontSize="small"
style={{ verticalAlign: "bottom", marginLeft: 4 }}
/>
</Link>
</>
) as any,
},
{
type: FieldType.contentHeader,
name: "_contentHeading_cloudRun",
label: "Functions Builder",
},
{
type: FieldType.shortText,
name: "buildUrl",
label: "Cloud Run Trigger URL",
format: "url",
assistiveText: (
<>
A Cloud Run instance is required to build and deploy {name} Cloud
Functions.
<Link href={WIKI_LINKS.functions} target="_blank" rel="noopener">
Learn more
<OpenInNewIcon
aria-label="Open in new tab"
fontSize="small"
style={{ verticalAlign: "bottom", marginLeft: 4 }}
/>
</Link>
<br />
To deploy the Cloud Run instance, click the button bellow and follow the
Cloud Shell prompts.
</>
) as any,
},
];

View File

@@ -1,62 +0,0 @@
import { FormDialog } from "@antlerengineering/form-builder";
import { projectSettingsForm } from "./form";
import useDoc, { DocActions } from "hooks/useDoc";
import { IFormDialogProps } from "components/Table/ColumnMenu/NewColumn";
import { Button } from "@material-ui/core";
export interface IProjectSettings
extends Pick<IFormDialogProps, "handleClose"> {
handleOpenBuilderInstaller: () => void;
}
export default function ProjectSettings({
handleClose,
handleOpenBuilderInstaller,
}: IProjectSettings) {
const [settingsState, settingsDispatch] = useDoc({
path: "_rowy_/settings",
});
const [publicSettingsState, publicSettingsDispatch] = useDoc({
path: "_rowy_/publicSettings",
});
if (settingsState.loading || publicSettingsState.loading) return null;
const handleSubmit = (v) => {
const { signInOptions, ...values } = v;
settingsDispatch({ action: DocActions.update, data: values });
publicSettingsDispatch({
action: DocActions.update,
data: { signInOptions },
});
};
const onOpenBuilderInstaller = () => {
handleClose();
window.open(
"https://deploy.cloud.run/?git_repo=https://github.com/rowyio/FunctionsBuilder.git",
"_blank"
);
handleOpenBuilderInstaller();
};
const hasCloudRunConfig = !!settingsState.doc.buildUrl;
return (
<FormDialog
onClose={handleClose}
title="Project Settings"
fields={projectSettingsForm}
values={{ ...settingsState.doc, ...publicSettingsState.doc }}
onSubmit={handleSubmit}
SubmitButtonProps={{ children: "Save" }}
formFooter={
hasCloudRunConfig ? null : (
<Button onClick={onOpenBuilderInstaller}>One click deploy</Button>
)
}
/>
);
}

View File

@@ -0,0 +1,205 @@
import { useEffect, useState } from "react";
import createPersistedState from "use-persisted-state";
import { Typography, Link, Button } from "@material-ui/core";
import LoadingButton from "@material-ui/lab/LoadingButton";
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import Modal from "components/Modal";
import { useRowyContext } from "contexts/RowyContext";
import { useConfirmation } from "components/ConfirmationDialog";
import { db, projectId } from "@src/firebase";
import { name } from "@root/package.json";
import { SETTINGS, TABLE_SCHEMAS } from "config/dbPaths";
import { WIKI_LINKS } from "constants/wikiLinks";
const useMigrateToV2State = createPersistedState("__ROWY__MIGRATE_TO_V2");
const checkIfMigrationRequired = async () => {
const oldSettingsExists = (await db.doc("_FIRETABLE_/settings").get()).exists;
if (!oldSettingsExists) return false;
const migrated =
(await db.doc(SETTINGS).get()).data()?.migratedToV2 !== undefined;
if (migrated) return false;
const tableSchemas = (
await db.collection("_FIRETABLE_/settings/schema").get()
).size;
if (tableSchemas === 0) return false;
return tableSchemas;
};
const migrate = async () => {
const oldSettings = (await db.doc("_FIRETABLE_/settings").get()).data() ?? {};
await db.doc(SETTINGS).set(oldSettings, { merge: true });
const tables = await db.collection("_FIRETABLE_/settings/schema").get();
const promises = tables.docs.map((table) =>
db
.collection(TABLE_SCHEMAS)
.doc(table.id)
.set(table.data(), { merge: true })
);
await Promise.all(promises);
await db.doc(SETTINGS).update({ migratedToV2: true });
};
export default function MigrateToV2() {
const { userClaims } = useRowyContext();
const { requestConfirmation } = useConfirmation();
const [requiresMigration, setRequiresMigration] = useMigrateToV2State<
null | false | number
>(null);
const [migrationStatus, setMigrationStatus] = useState<
"IDLE" | "MIGRATING" | "COMPLETE"
>("IDLE");
useEffect(() => {
if (
Array.isArray(userClaims?.roles) &&
userClaims?.roles.includes("ADMIN") &&
requiresMigration !== false
) {
checkIfMigrationRequired().then(setRequiresMigration);
}
}, [userClaims, requiresMigration, setRequiresMigration]);
if (migrationStatus === "COMPLETE")
return (
<Modal
title={`Welcome to ${name}!`}
onClose={() => {
setMigrationStatus("IDLE");
setRequiresMigration(false);
}}
hideCloseButton
maxWidth="xs"
body="Your project settings and tables have been successfully migrated."
actions={{
primary: {
children: "Continue",
onClick: () => {
setMigrationStatus("IDLE");
},
},
}}
/>
);
if (!requiresMigration) return null;
const freshStart = async () => {
await db.doc(SETTINGS).update({ migratedToV2: false });
setRequiresMigration(false);
};
return (
<Modal
title={`Migrate to ${name}`}
onClose={() => {}}
disableBackdropClick
disableEscapeKeyDown
hideCloseButton
maxWidth="xs"
body={
<>
<div>
<Typography gutterBottom>
It looks like your Firestore database is configured for Firetable.
You can migrate your configuration, including your{" "}
<strong>{requiresMigration} tables</strong>, to {name}.
</Typography>
<Typography>
Alternatively, you can{" "}
<Link
onClick={() => {
requestConfirmation({
title: "Continue without migrating?",
body: `You will start a new ${name} project without your existing config or tables.`,
handleConfirm: freshStart,
});
}}
component="button"
color="inherit"
variant="body2"
display="inline"
style={{ verticalAlign: "baseline" }}
>
skip migration
</Link>
.
</Typography>
</div>
<div>
<Typography variant="subtitle1" gutterBottom>
1. Update your Firestore Security Rules
</Typography>
<Typography>
Add the required rules to your Firestore Security Rules.
</Typography>
</div>
<Button
href={WIKI_LINKS.securityRules + "#required-rules"}
target="_blank"
rel="noopener noreferrer"
endIcon={<OpenInNewIcon aria-label="Open in new tab" />}
fullWidth
sx={{ mt: 1 }}
>
Copy Required Rules
</Button>
<Button
href={`https://console.firebase.google.com/project/${projectId}/firestore/rules`}
target="_blank"
rel="noopener noreferrer"
endIcon={<OpenInNewIcon aria-label="Open in new tab" />}
fullWidth
sx={{ mt: 1 }}
>
Set Rules in Firebase Console
</Button>
<div>
<Typography variant="subtitle1" gutterBottom>
2. Migrate your config and tables
</Typography>
<LoadingButton
variant="contained"
color="primary"
fullWidth
loading={migrationStatus === "MIGRATING"}
onClick={() => {
setMigrationStatus("MIGRATING");
migrate()
.then(() => {
setMigrationStatus("COMPLETE");
setRequiresMigration(false);
})
.catch((e) => {
setMigrationStatus("IDLE");
alert(
"Failed to migrate. Please check you have set the Firestore Security Rules correctly.\n\n" +
e.message
);
});
}}
>
Migrate
</LoadingButton>
</div>
</>
}
/>
);
}

View File

@@ -0,0 +1,208 @@
import { useState, useCallback, useEffect } from "react";
import createPersistedState from "use-persisted-state";
import { differenceInDays } from "date-fns";
import { Grid, Typography, Button, Link, Divider } from "@material-ui/core";
import LoadingButton from "@material-ui/lab/LoadingButton";
import Logo from "assets/Logo";
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import { name, version, repository } from "@root/package.json";
import { projectId } from "@src/firebase";
import WIKI_LINKS from "constants/wikiLinks";
const useLastCheckedUpdateState = createPersistedState(
"__ROWY__LAST_CHECKED_UPDATE"
);
export const useLatestUpdateState = createPersistedState(
"__ROWY__LATEST_UPDATE"
);
export default function About() {
const [lastCheckedUpdate, setLastCheckedUpdate] =
useLastCheckedUpdateState<string>();
const [latestUpdate, setLatestUpdate] = useLatestUpdateState<null | Record<
string,
any
>>(null);
const [checkState, setCheckState] = useState<null | "LOADING" | "NO_UPDATE">(
null
);
const checkForUpdate = useCallback(async () => {
setCheckState("LOADING");
// https://docs.github.com/en/rest/reference/repos#get-the-latest-release
const endpoint = repository.url
.replace("github.com", "api.github.com/repos")
.replace(/.git$/, "/releases/latest");
try {
const res = await fetch(endpoint, {
headers: {
Accept: "application/vnd.github.v3+json",
},
});
const json = await res.json();
if (json.tag_name > "v" + version) {
setLatestUpdate(json);
setCheckState(null);
} else {
setCheckState("NO_UPDATE");
}
setLastCheckedUpdate(new Date().toISOString());
} catch (e) {
console.error(e);
setLatestUpdate(null);
setCheckState("NO_UPDATE");
}
}, [setLastCheckedUpdate, setLatestUpdate]);
// Check for new updates on page load, if last check was more than 7 days ago
useEffect(() => {
if (!lastCheckedUpdate) checkForUpdate();
else if (differenceInDays(new Date(), new Date(lastCheckedUpdate)) > 7)
checkForUpdate();
}, [lastCheckedUpdate, checkForUpdate]);
// Verify latest update is not installed yet
useEffect(() => {
if (latestUpdate?.tag_name <= "v" + version) setLatestUpdate(null);
}, [latestUpdate, setLatestUpdate]);
return (
<>
<Grid
container
spacing={1}
alignItems="center"
justifyContent="space-between"
>
<Grid item>
<Logo />
</Grid>
<Grid item style={{ textAlign: "right" }}>
<Link
component="a"
href={repository.url.replace(".git", "")}
target="_blank"
rel="noopener noreferrer"
variant="body2"
display="block"
>
GitHub
<OpenInNewIcon
aria-label="Open in new tab"
fontSize="small"
sx={{ verticalAlign: "bottom", ml: 0.5 }}
/>
</Link>
<Link
component="a"
href={repository.url.replace(".git", "") + "/releases"}
target="_blank"
rel="noopener noreferrer"
variant="body2"
display="block"
>
Release notes
<OpenInNewIcon
aria-label="Open in new tab"
fontSize="small"
sx={{ verticalAlign: "bottom", ml: 0.5 }}
/>
</Link>
</Grid>
</Grid>
<div>
<Grid
container
spacing={1}
alignItems="center"
justifyContent="space-between"
>
<Grid item>
<Typography display="block">
{name} v{version}
</Typography>
{latestUpdate === null ? (
<Typography color="textSecondary" display="block">
Up to date
</Typography>
) : (
<Link
href={latestUpdate.html_url}
target="_blank"
rel="noopener noreferrer"
variant="body2"
display="block"
>
Update available: {latestUpdate.tag_name}
<OpenInNewIcon
aria-label="Open in new tab"
fontSize="small"
sx={{ verticalAlign: "bottom", ml: 0.5 }}
/>
</Link>
)}
</Grid>
<Grid item>
{latestUpdate === null ? (
<LoadingButton
onClick={checkForUpdate}
loading={checkState === "LOADING"}
>
Check for Updates
</LoadingButton>
) : (
<Button
href={WIKI_LINKS.updating}
target="_blank"
rel="noopener noreferrer"
endIcon={<OpenInNewIcon aria-label="Open in new tab" />}
>
How to Update
</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
<OpenInNewIcon
aria-label="Open in new tab"
fontSize="small"
sx={{ verticalAlign: "bottom", ml: 0.5 }}
/>
</Link>
</Grid>
</Grid>
</div>
</>
);
}

View File

@@ -0,0 +1,54 @@
import { useState } from "react";
import { authOptions } from "firebase/firebaseui";
import _startCase from "lodash/startCase";
import MultiSelect from "@antlerengineering/multiselect";
import { Typography, Link } from "@material-ui/core";
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import { IProjectSettingsChildProps } from "pages/Settings/ProjectSettings";
export default function Authentication({
publicSettings,
updatePublicSettings,
}: IProjectSettingsChildProps) {
const [signInOptions, setSignInOptions] = useState(
Array.isArray(publicSettings?.signInOptions)
? publicSettings.signInOptions
: ["google"]
);
return (
<>
<MultiSelect
label="Sign-In Options"
value={signInOptions}
options={Object.keys(authOptions).map((option) => ({
value: option,
label: _startCase(option).replace("Github", "GitHub"),
}))}
onChange={setSignInOptions}
onClose={() => updatePublicSettings({ signInOptions })}
multiple
TextFieldProps={{ id: "signInOptions" }}
/>
<Typography>
Before enabling a new sign-in option, make sure its configured in your
Firebase project.{" "}
<Link
href={`https://github.com/firebase/firebaseui-web#configuring-sign-in-providers`}
target="_blank"
rel="noopener"
>
How to configure sign-in options
<OpenInNewIcon
aria-label="Open in new tab"
fontSize="small"
sx={{ verticalAlign: "bottom", ml: 0.5 }}
/>
</Link>
</Typography>
</>
);
}

View File

@@ -0,0 +1,81 @@
import { Typography, Link, Grid, TextField } from "@material-ui/core";
import LoadingButton from "@material-ui/lab/LoadingButton";
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import { IProjectSettingsChildProps } from "pages/Settings/ProjectSettings";
import WIKI_LINKS from "constants/wikiLinks";
import { repository } from "@root/package.json";
export default function FunctionsBuilder({
settings,
updateSettings,
}: IProjectSettingsChildProps) {
return (
<>
<Typography>
Functions Builder is a Cloud Run instance that deploys this projects
Cloud Functions.{" "}
<Link
href={WIKI_LINKS.functions}
target="_blank"
rel="noopener noreferrer"
>
Learn more
<OpenInNewIcon
aria-label="Open in new tab"
fontSize="small"
sx={{ verticalAlign: "bottom", ml: 0.5 }}
/>
</Link>
</Typography>
<div>
<Grid
container
spacing={1}
alignItems="center"
justifyContent="space-between"
>
<Grid item xs={12} sm>
<Typography>
If you have not yet deployed Functions Builder, click this button
and follow the prompts on Cloud Shell.
</Typography>
</Grid>
<Grid item>
<LoadingButton
href={`https://deploy.cloud.run/?git_repo=${repository.url
.split("/")
.slice(0, -1)
.join("/")}/FunctionsBuilder.git`}
target="_blank"
rel="noopener noreferrer"
endIcon={<OpenInNewIcon aria-label="Open in new tab" />}
loading={
settings.buildStatus === "BUILDING" ||
settings.buildStatus === "COMPLETE"
}
loadingIndicator={
settings.buildStatus === "COMPLETE" ? "Deployed" : undefined
}
>
Deploy to Cloud Run
</LoadingButton>
</Grid>
</Grid>
</div>
<TextField
label="Cloud Run Instance URL"
id="buildUrl"
defaultValue={settings.buildUrl}
onChange={(e) => updateSettings({ buildUrl: e.target.value })}
fullWidth
placeholder="https://<id>.run.app"
type="url"
autoComplete="url"
/>
</>
);
}

View File

@@ -0,0 +1,36 @@
import { Typography, Paper } from "@material-ui/core";
export interface ISettingsSectionProps {
children: React.ReactNode;
title: string;
}
export default function SettingsSection({
children,
title,
}: ISettingsSectionProps) {
return (
<section style={{ cursor: "default" }}>
<Typography
variant="subtitle1"
component="h2"
sx={{ mx: 1, mb: 0.5 }}
id={title}
>
{title}
</Typography>
<Paper
sx={{
p: { xs: 2, sm: 3 },
"& > :not(style) + :not(style)": {
m: 0,
mt: { xs: 2, sm: 3 },
},
}}
>
{children}
</Paper>
</section>
);
}

View File

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

View File

@@ -19,19 +19,13 @@ import { FieldType } from "constants/fields";
import Typography from "@material-ui/core/Typography";
import Divider from "@material-ui/core/Divider";
import Subheading from "components/Table/ColumnMenu/Subheading";
import Button from "@material-ui/core/Button";
import routes from "constants/routes";
import { SETTINGS } from "config/dbPaths";
export default function FieldSettings(props: IMenuModalProps) {
const {
name,
fieldName,
type,
open,
config,
handleClose,
handleSave,
} = props;
const { name, fieldName, type, open, config, handleClose, handleSave } =
props;
const [showRebuildPrompt, setShowRebuildPrompt] = useState(false);
const [newConfig, setNewConfig] = useState(config ?? {});
@@ -125,12 +119,11 @@ export default function FieldSettings(props: IMenuModalProps) {
if (showRebuildPrompt) {
requestConfirmation({
title: "Deploy Changes",
body:
"You have made changes that affect the behavior of the cloud function of this table, Would you like to redeploy it now?",
body: "You have made changes that affect the behavior of the cloud function of this table, Would you like to redeploy it now?",
confirm: "Deploy",
cancel: "Later",
handleConfirm: async () => {
const settingsDoc = await db.doc("/_rowy_/settings").get();
const settingsDoc = await db.doc(SETTINGS).get();
const buildUrl = settingsDoc.get("buildUrl");
if (!buildUrl) {
snack.open({
@@ -149,7 +142,8 @@ export default function FieldSettings(props: IMenuModalProps) {
),
});
}
const userTokenInfo = await appContext?.currentUser?.getIdTokenResult();
const userTokenInfo =
await appContext?.currentUser?.getIdTokenResult();
const userToken = userTokenInfo?.token;
try {
snackLog.requestSnackLog();

View File

@@ -23,6 +23,7 @@ import {
IExtension,
IExtensionType,
} from "./utils";
import { SETTINGS } from "config/dbPaths";
import WIKI_LINKS from "constants/wikiLinks";
export default function ExtensionsEditor() {
@@ -92,7 +93,7 @@ export default function ExtensionsEditor() {
const serialisedExtension = serialiseExtension(localExtensionsObjects);
tableActions?.table.updateConfig("extensions", serialisedExtension);
const settingsDoc = await db.doc("/_rowy_/settings").get();
const settingsDoc = await db.doc(SETTINGS).get();
const buildUrl = settingsDoc.get("buildUrl");
if (!buildUrl) {
snack.open({

View File

@@ -38,6 +38,7 @@ import EmptyState from "components/EmptyState";
import PropTypes from "prop-types";
import routes from "constants/routes";
import { DATE_TIME_FORMAT } from "constants/dates";
import { SETTINGS, TABLE_SCHEMAS, TABLE_GROUP_SCHEMAS } from "config/dbPaths";
function a11yProps(index) {
return {
@@ -183,9 +184,8 @@ function LogPanel(props) {
// useStateRef is necessary to resolve the state syncing issue
// https://stackoverflow.com/a/63039797/12208834
const [liveStreaming, setLiveStreaming, liveStreamingStateRef] = useStateRef(
true
);
const [liveStreaming, setLiveStreaming, liveStreamingStateRef] =
useStateRef(true);
const liveStreamingRef = useRef<any>();
const isActive = value === index;
@@ -264,9 +264,8 @@ function SnackLog({ log, onClose, onOpenPanel }) {
const status = log?.status;
const classes = useStyles();
const [expanded, setExpanded] = useState(false);
const [liveStreaming, setLiveStreaming, liveStreamingStateRef] = useStateRef(
true
);
const [liveStreaming, setLiveStreaming, liveStreamingStateRef] =
useStateRef(true);
const liveStreamingRef = useRef<any>();
const handleScroll = _throttle(() => {
@@ -403,7 +402,7 @@ export default function TableLogs() {
}, []);
const checkBuildURL = async () => {
const settingsDoc = await db.doc("/_rowy_/settings").get();
const settingsDoc = await db.doc(SETTINGS).get();
const buildUrl = settingsDoc.get("buildUrl");
if (!buildUrl) {
setBuildURLConfigured(false);
@@ -412,8 +411,8 @@ export default function TableLogs() {
const tableCollection = decodeURIComponent(router.match.params.id);
const buildStreamID =
"_rowy_/settings/" +
`${isCollectionGroup() ? "groupSchema/" : "schema/"}` +
(isCollectionGroup() ? TABLE_GROUP_SCHEMAS : TABLE_SCHEMAS) +
"/" +
tableCollection
.split("/")
.filter(function (_, i) {

View File

@@ -14,6 +14,7 @@ import { useRowyContext } from "contexts/RowyContext";
import useRouter from "../../hooks/useRouter";
import { db } from "../../firebase";
import { name } from "@root/package.json";
import { SETTINGS, TABLE_SCHEMAS } from "config/dbPaths";
export enum TableSettingsDialogModes {
create,
@@ -123,13 +124,13 @@ export default function TableSettingsDialog({
};
const handleResetStructure = async () => {
const schemaDocRef = db.doc(`_rowy_/settings/table/${data!.collection}`);
const schemaDocRef = db.doc(`${TABLE_SCHEMAS}/${data!.collection}`);
await schemaDocRef.update({ columns: {} });
handleClose();
};
const handleDelete = async () => {
const tablesDocRef = db.doc(`_rowy_/settings`);
const tablesDocRef = db.doc(SETTINGS);
const tableData = (await tablesDocRef.get()).data();
const updatedTables = tableData?.tables.filter(
(table) =>

View File

@@ -9,6 +9,7 @@ import MultiSelect from "@antlerengineering/multiselect";
import { FieldType } from "constants/fields";
import { db } from "../../../firebase";
import { useRowyContext } from "contexts/RowyContext";
import { TABLE_SCHEMAS } from "config/dbPaths";
export default function Settings({ handleChange, config }: ISettingsProps) {
const { tables } = useRowyContext();
@@ -24,7 +25,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
{ value: string; label: string; type: FieldType }[]
>([]);
const getColumns = async (table) => {
const tableConfigDoc = await db.doc(`_rowy_/settings/table/${table}`).get();
const tableConfigDoc = await db.doc(`${TABLE_SCHEMAS}/${table}`).get();
const tableConfig = tableConfigDoc.data();
if (tableConfig && tableConfig.columns)
setColumns(

7
src/config/dbPaths.ts Normal file
View File

@@ -0,0 +1,7 @@
export const SETTINGS = "_rowy_/settings";
export const PUBLIC_SETTINGS = "_rowy_/publicSettings";
export const TABLE_SCHEMAS = SETTINGS + "/schema";
export const TABLE_GROUP_SCHEMAS = SETTINGS + "/groupSchema";
export const USERS = SETTINGS + "/users";

View File

@@ -18,6 +18,7 @@ export enum routes {
settings = "/settings",
userSettings = "/settings/user",
projectSettings = "/settings/project",
userManagement = "/settings/userManagement",
}
export default routes;

View File

@@ -14,6 +14,7 @@ import Themes from "Themes";
import ErrorBoundary from "components/ErrorBoundary";
import { name } from "@root/package.json";
import { USERS } from "config/dbPaths";
const useThemeState = createPersistedState("__ROWY__THEME");
const useThemeOverriddenState = createPersistedState(
@@ -62,7 +63,7 @@ export const AppProvider: React.FC = ({ children }) => {
if (currentUser) {
analytics.setUserId(currentUser.uid);
analytics.setUserProperties({ instance: window.location.hostname });
dispatchUserDoc({ path: `_rowy_/settings/users/${currentUser.uid}` });
dispatchUserDoc({ path: `${USERS}/${currentUser.uid}` });
}
}, [currentUser]);

View File

@@ -15,7 +15,10 @@ const documentInitialState = {
error: null,
};
const useDoc = (initialOverrides: any) => {
const useDoc = (
initialOverrides: any,
options: { createIfMissing?: boolean } = {}
) => {
const documentReducer = (prevState: any, newProps: any) => {
switch (newProps.action) {
case DocActions.clear:
@@ -43,7 +46,7 @@ const useDoc = (initialOverrides: any) => {
...initialOverrides,
});
const setDocumentListner = () => {
const setDocumentListener = () => {
documentDispatch({ prevPath: documentState.path });
const unsubscribe = db.doc(documentState.path).onSnapshot(
(snapshot) => {
@@ -58,9 +61,17 @@ const useDoc = (initialOverrides: any) => {
loading: false,
});
} else {
documentDispatch({
loading: false,
});
if (options.createIfMissing)
try {
db.doc(documentState.path).set({}, { merge: true });
} catch (e) {
console.error(
`Could not create ${documentState.path}`,
e.message
);
}
documentDispatch({ loading: false });
}
},
(error) => {
@@ -76,7 +87,7 @@ const useDoc = (initialOverrides: any) => {
const { path, prevPath, unsubscribe } = documentState;
if (path && path !== prevPath) {
if (unsubscribe) unsubscribe();
setDocumentListner();
setDocumentListener();
}
}, [documentState]);
useEffect(

View File

@@ -1,11 +1,10 @@
import { useEffect } from "react";
import useDoc from "./useDoc";
import { db } from "../firebase";
import { SETTINGS, TABLE_GROUP_SCHEMAS, TABLE_SCHEMAS } from "config/dbPaths";
const useSettings = () => {
const [settingsState, documentDispatch] = useDoc({
path: "_rowy_/settings",
});
const [settingsState, documentDispatch] = useDoc({ path: SETTINGS });
useEffect(() => {
//updates tables data on document change
const { doc, tables } = settingsState;
@@ -31,22 +30,26 @@ const useSettings = () => {
}) => {
const { tables } = settingsState;
const { schemaSource, ...tableSettings } = data;
const tableSchemaPath = `_rowy_/settings/${
tableSettings.tableType !== "collectionGroup" ? "schema" : "groupSchema"
const tableSchemaPath = `${
tableSettings.tableType !== "collectionGroup"
? TABLE_SCHEMAS
: TABLE_GROUP_SCHEMAS
}/${tableSettings.collection}`;
const tableSchemaDocRef = db.doc(tableSchemaPath);
let columns = {};
if (schemaSource) {
const schemaSourcePath = `_rowy_/settings/${
schemaSource.tableType !== "collectionGroup" ? "schema" : "groupSchema"
const schemaSourcePath = `${
tableSettings.tableType !== "collectionGroup"
? TABLE_SCHEMAS
: TABLE_GROUP_SCHEMAS
}/${schemaSource.collection}`;
const sourceDoc = await db.doc(schemaSourcePath).get();
columns = sourceDoc.get("columns");
}
// updates the setting doc
await db
.doc("_rowy_/settings")
.doc(SETTINGS)
.set(
{ tables: tables ? [...tables, tableSettings] : [tableSettings] },
{ merge: true }
@@ -65,7 +68,7 @@ const useSettings = () => {
const { tables } = settingsState;
const table = tables.filter((t) => t.collection === data.collection)[0];
return Promise.all([
db.doc("_rowy_/settings").set(
db.doc(SETTINGS).set(
{
tables: tables
? [
@@ -80,7 +83,7 @@ const useSettings = () => {
),
//update the rowy collection doc with empty columns
db
.collection("_rowy_/settings/table")
.collection(TABLE_SCHEMAS)
.doc(data.collection)
.set({ ...data }, { merge: true }),
]);
@@ -88,10 +91,10 @@ const useSettings = () => {
const deleteTable = (collection: string) => {
const { tables } = settingsState;
db.doc("_rowy_/settings").update({
db.doc(SETTINGS).update({
tables: tables.filter((table) => table.collection !== collection),
});
db.collection("_rowy_/settings/table").doc(collection).delete();
db.collection(TABLE_SCHEMAS).doc(collection).delete();
};
const settingsActions = { createTable, updateTable, deleteTable };
return [settingsState, settingsActions];

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import queryString from "query-string";
import _find from "lodash/find";
import { makeStyles, createStyles } from "@material-ui/styles";
@@ -11,17 +12,17 @@ import {
Checkbox,
Tooltip,
IconButton,
Link,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
// import SettingsIcon from "@material-ui/icons/Settings";
import EditIcon from "@material-ui/icons/Edit";
import Favorite from "@material-ui/icons/Favorite";
import FavoriteBorder from "@material-ui/icons/FavoriteBorder";
import SecurityIcon from "@material-ui/icons/SecurityOutlined";
import Navigation from "components/Navigation";
import Logo from "assets/Logo";
import StyledCard from "components/StyledCard";
import HomeWelcomePrompt from "components/HomeWelcomePrompt";
import EmptyState from "components/EmptyState";
import routes from "constants/routes";
import { useRowyContext } from "contexts/RowyContext";
@@ -31,12 +32,8 @@ import TableSettingsDialog, {
TableSettingsDialogModes,
} from "components/TableSettings";
import queryString from "query-string";
import ProjectSettings from "components/ProjectSettings";
import EmptyState from "components/EmptyState";
import WIKI_LINKS from "constants/wikiLinks";
import BuilderInstaller from "../components/BuilderInstaller";
import HomeWelcomePrompt from "components/HomeWelcomePrompt";
import { SETTINGS } from "config/dbPaths";
const useStyles = makeStyles((theme) =>
createStyles({
@@ -136,9 +133,7 @@ export default function HomePage() {
const [openProjectSettings, setOpenProjectSettings] = useState(false);
const [openBuilderInstaller, setOpenBuilderInstaller] = useState(false);
const [settingsDocState, settingsDocDispatch] = useDoc({
path: "_rowy_/settings",
});
const [settingsDocState, settingsDocDispatch] = useDoc({ path: SETTINGS });
useEffect(() => {
if (!settingsDocState.loading && !settingsDocState.doc) {
settingsDocDispatch({
@@ -151,22 +146,23 @@ export default function HomePage() {
return (
<EmptyState
fullScreen
Icon={SecurityIcon}
message="Access Denied"
description={
<>
<Typography variant="overline">
<Typography>
You do not have access to this project. Please contact the project
owner.
</Typography>
<Typography variant="body2">
<Typography>
If you are the project owner, please follow{" "}
<a
<Link
href={WIKI_LINKS.securityRules}
target="_blank"
rel="noopener noreferrer"
>
the instructions
</a>{" "}
these instructions
</Link>{" "}
to set up the project rules.
</Typography>
</>
@@ -228,125 +224,95 @@ export default function HomePage() {
};
return (
<Navigation
title={
<div style={{ textAlign: "center" }}>
<Logo />
</div>
}
>
<main className={classes.root}>
{sections && Object.keys(sections).length > 0 ? (
<Container>
{favs.length !== 0 && (
<section id="favorites" className={classes.section}>
<main className={classes.root}>
{sections && Object.keys(sections).length > 0 ? (
<Container>
{favs.length !== 0 && (
<section id="favorites" className={classes.section}>
<Typography
variant="h6"
component="h1"
className={classes.sectionHeader}
>
Favorites
</Typography>
<Divider className={classes.divider} />
<Grid
container
spacing={4}
justifyContent="flex-start"
className={classes.cardGrid}
>
{favs.map((table) => (
<TableCard key={table.collection} table={table} />
))}
</Grid>
</section>
)}
{sections &&
Object.keys(sections).length > 0 &&
Object.keys(sections).map((sectionName) => (
<section
key={sectionName}
id={sectionName}
className={classes.section}
>
<Typography
variant="h6"
component="h1"
className={classes.sectionHeader}
>
Favorites
{sectionName === "undefined" ? "Other" : sectionName}
</Typography>
<Divider className={classes.divider} />
<Grid
container
spacing={4}
justifyContent="flex-start"
className={classes.cardGrid}
>
{favs.map((table) => (
<TableCard key={table.collection} table={table} />
{sections[sectionName].map((table, i) => (
<TableCard key={`${i}-${table.collection}`} table={table} />
))}
</Grid>
</section>
)}
))}
{sections &&
Object.keys(sections).length > 0 &&
Object.keys(sections).map((sectionName) => (
<section
key={sectionName}
id={sectionName}
className={classes.section}
>
<Typography
variant="h6"
component="h1"
className={classes.sectionHeader}
>
{sectionName === "undefined" ? "Other" : sectionName}
</Typography>
<Divider className={classes.divider} />
<Grid
container
spacing={4}
justifyContent="flex-start"
className={classes.cardGrid}
>
{sections[sectionName].map((table, i) => (
<TableCard
key={`${i}-${table.collection}`}
table={table}
/>
))}
</Grid>
</section>
))}
<section className={classes.section}>
<Tooltip title="Create Table">
<Fab
className={classes.fab}
color="secondary"
aria-label="Create table"
onClick={handleCreateTable}
>
<AddIcon />
</Fab>
</Tooltip>
{/* <Tooltip title="Configure Rowy">
<section className={classes.section}>
<Tooltip title="Create Table">
<Fab
className={classes.configFab}
className={classes.fab}
color="secondary"
aria-label="Create table"
onClick={() => setOpenProjectSettings(true)}
onClick={handleCreateTable}
>
<SettingsIcon />
<AddIcon />
</Fab>
</Tooltip> */}
</section>
</Container>
) : (
<Container>
<HomeWelcomePrompt />
<Fab
className={classes.fab}
color="secondary"
aria-label="Create table"
onClick={handleCreateTable}
>
<AddIcon />
</Fab>
</Container>
)}
</main>
</Tooltip>
</section>
</Container>
) : (
<Container>
<HomeWelcomePrompt />
<Fab
className={classes.fab}
color="secondary"
aria-label="Create table"
onClick={handleCreateTable}
>
<AddIcon />
</Fab>
</Container>
)}
<TableSettingsDialog
clearDialog={clearDialog}
mode={settingsDialogState.mode}
data={settingsDialogState.data}
/>
{openProjectSettings && (
<ProjectSettings
handleClose={() => setOpenProjectSettings(false)}
handleOpenBuilderInstaller={() => setOpenBuilderInstaller(true)}
/>
)}
{openBuilderInstaller && (
<BuilderInstaller handleClose={() => setOpenBuilderInstaller(false)} />
)}
</Navigation>
</main>
);
}

View File

@@ -1,15 +1,95 @@
import { Container, Typography } from "@material-ui/core";
import { Container, Stack } from "@material-ui/core";
import Navigation from "components/Navigation";
import SettingsSkeleton from "components/Settings/SettingsSkeleton";
import SettingsSection from "components/Settings/SettingsSection";
import About from "components/Settings/ProjectSettings/About";
import Authentication from "components/Settings/ProjectSettings/Authentication";
import FunctionsBuilder from "components/Settings/ProjectSettings/FunctionsBuilder";
import { SETTINGS, PUBLIC_SETTINGS } from "config/dbPaths";
import useDoc from "hooks/useDoc";
import { db } from "@src/firebase";
import { useSnackContext } from "contexts/SnackContext";
import { useDebouncedCallback } from "use-debounce";
import { useEffect } from "react";
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 ProjectSettings() {
const snack = useSnackContext();
const [settingsState] = useDoc({ path: SETTINGS }, { createIfMissing: true });
const settings = settingsState.doc;
const [updateSettings, , callPending] = useDebouncedCallback(
(data: Record<string, any>) =>
db
.doc(SETTINGS)
.update(data)
.then(() =>
snack.open({ message: "Saved", variant: "success", duration: 3000 })
),
1000
);
const [publicSettingsState] = useDoc(
{ path: PUBLIC_SETTINGS },
{ createIfMissing: true }
);
const publicSettings = publicSettingsState.doc;
const [updatePublicSettings, , callPendingPublic] = useDebouncedCallback(
(data: Record<string, any>) =>
db
.doc(PUBLIC_SETTINGS)
.update(data)
.then(() =>
snack.open({ message: "Saved", variant: "success", duration: 3000 })
),
1000
);
const childProps: IProjectSettingsChildProps = {
settings,
updateSettings,
publicSettings,
updatePublicSettings,
};
useEffect(
() => () => {
callPending();
callPendingPublic();
},
[]
);
return (
<Navigation title="Project Settings">
<Container style={{ height: "200vh" }}>
<Typography component="h2" variant="h4">
Project Settings
</Typography>
</Container>
</Navigation>
<Container maxWidth="sm" sx={{ px: 1, pt: 2, pb: 7 }}>
{settingsState.loading || publicSettingsState.loading ? (
<Stack spacing={4}>
<SettingsSkeleton />
<SettingsSkeleton />
<SettingsSkeleton />
</Stack>
) : (
<Stack spacing={4}>
<SettingsSection title="About">
<About />
</SettingsSection>
<SettingsSection title="Authentication">
<Authentication {...childProps} />
</SettingsSection>
<SettingsSection title="Functions Builder">
<FunctionsBuilder {...childProps} />
</SettingsSection>
</Stack>
)}
</Container>
);
}

View File

@@ -0,0 +1,13 @@
import { Container, Stack } from "@material-ui/core";
import SettingsSection from "components/Settings/SettingsSection";
export default function UserManagement() {
return (
<Container maxWidth="sm" sx={{ px: 1, pt: 2, pb: 7 }}>
<Stack spacing={4}>
<SettingsSection title="Users">TODO:</SettingsSection>
</Stack>
</Container>
);
}

View File

@@ -1,15 +1,13 @@
import { Container, Typography } from "@material-ui/core";
import { Container, Stack } from "@material-ui/core";
import Navigation from "components/Navigation";
import SettingsSection from "components/Settings/SettingsSection";
export default function UserSettings() {
return (
<Navigation title="Settings">
<Container>
<Typography component="h2" variant="h4">
Settings
</Typography>
</Container>
</Navigation>
<Container maxWidth="sm" sx={{ px: 1, pt: 2, pb: 7 }}>
<Stack spacing={4}>
<SettingsSection title="Your Account">TODO:</SettingsSection>
</Stack>
</Container>
);
}

View File

@@ -76,7 +76,11 @@ export default function TablePage() {
if (!tableState) return null;
return (
<Navigation title={<Breadcrumbs />}>
<Navigation
title={<Breadcrumbs />}
currentSection={currentSection}
currentTable={currentTable}
>
<ActionParamsProvider>
{tableState.loadingColumns && (
<>

View File

@@ -64,7 +64,9 @@ export const components = (theme: Theme): ThemeOptions => {
padding: "0 var(--dialog-spacing)",
},
paperWidthXs: { maxWidth: 360 },
paperWidthXs: {
[theme.breakpoints.up("sm")]: { maxWidth: 360 },
},
paperFullScreen: {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
@@ -130,10 +132,6 @@ export const components = (theme: Theme): ThemeOptions => {
},
},
MuiTypography: {
defaultProps: { variant: "body2" },
},
MuiTextField: {
defaultProps: {
variant: "filled",
@@ -208,6 +206,7 @@ export const components = (theme: Theme): ThemeOptions => {
...theme.typography.caption,
fontWeight: 500,
color: theme.palette.text.primary,
},
},
},

View File

@@ -144,5 +144,10 @@ export const typography = ({
lineHeight: 20 / 12,
},
},
components: {
MuiTypography: {
defaultProps: { variant: "body2" },
},
},
};
};

View File

@@ -1,4 +1,6 @@
import _get from "lodash/get";
import { TABLE_GROUP_SCHEMAS, TABLE_SCHEMAS } from "config/dbPaths";
/**
* reposition an element in an array
* @param arr array
@@ -26,14 +28,12 @@ export const arrayMover = (
return arr; // for testing purposes
};
export const missingFieldsReducer = (data: any) => (
acc: string[],
curr: string
) => {
if (data[curr] === undefined) {
return [...acc, curr];
} else return acc;
};
export const missingFieldsReducer =
(data: any) => (acc: string[], curr: string) => {
if (data[curr] === undefined) {
return [...acc, curr];
} else return acc;
};
export const sanitiseCallableName = (name: string) => {
if (!name || typeof name !== "string") return "";
@@ -96,8 +96,8 @@ export const generateBiggerId = (id: string) => {
const formatPathRegex = /\/[^\/]+\/([^\/]+)/g;
export const formatPath = (tablePath: string) => {
return `_rowy_/settings/${
isCollectionGroup() ? "groupSchema" : "schema"
return `${
isCollectionGroup() ? TABLE_GROUP_SCHEMAS : TABLE_SCHEMAS
}/${tablePath.replace(formatPathRegex, "/subTables/$1")}`;
};