Setup: add step 1

This commit is contained in:
Sidney Alcantara
2021-09-15 18:57:08 +10:00
parent ce18dc26bd
commit 91f8398333
15 changed files with 625 additions and 102 deletions

View File

@@ -102,7 +102,6 @@ export default function App() {
routes.home,
routes.tableWithId,
routes.tableGroupWithId,
routes.gridWithId,
routes.settings,
routes.projectSettings,
routes.userSettings,

View File

@@ -1,13 +1,17 @@
import { SVGProps } from "react";
import { useTheme } from "@mui/material";
export default function Logo(props: SVGProps<SVGSVGElement>) {
export interface ILogoProps extends SVGProps<SVGSVGElement> {
size?: number;
}
export default function Logo({ size = 1.5, ...props }: ILogoProps) {
const theme = useTheme();
return (
<svg
width="102"
height="32"
width={Math.round(68 * size)}
height={Math.round(21 * size)}
viewBox="0 -1.5 68 21"
xmlns="http://www.w3.org/2000/svg"
aria-labelledby="rowy-logo-title"

View File

@@ -6,7 +6,13 @@ export default function InlineOpenInNewIcon(props: SvgIconProps) {
<OpenInNewIcon
aria-label="Open in new tab"
{...props}
sx={{ fontSize: 16, verticalAlign: "text-bottom", ml: 0.5, ...props.sx }}
sx={{
fontSize: 16,
verticalAlign: "text-bottom",
ml: 0.5,
opacity: 0.6,
...props.sx,
}}
/>
);
}

View File

@@ -35,31 +35,25 @@ export default function ScrollableDialogContent({
return (
<>
<Divider
style={{
visibility:
!disableTopDivider &&
scrollInfo.y.percentage !== null &&
scrollInfo.y.percentage > 0
? "visible"
: "hidden",
}}
sx={{ ...dividerSx, ...topDividerSx }}
/>
{!disableTopDivider && scrollInfo.y.percentage !== null && (
<Divider
style={{
visibility: scrollInfo.y.percentage > 0 ? "visible" : "hidden",
}}
sx={{ mb: "-1px", ...dividerSx, ...topDividerSx }}
/>
)}
<MemoizedDialogContent {...props} setRef={setRef} />
<Divider
style={{
visibility:
!disableBottomDivider &&
scrollInfo.y.percentage !== null &&
scrollInfo.y.percentage < 1
? "visible"
: "hidden",
}}
sx={{ ...dividerSx, ...bottomDividerSx }}
/>
{!disableBottomDivider && scrollInfo.y.percentage !== null && (
<Divider
style={{
visibility: scrollInfo.y.percentage < 1 ? "visible" : "hidden",
}}
sx={{ mt: "-1px", ...dividerSx, ...bottomDividerSx }}
/>
)}
</>
);
}

View File

@@ -41,15 +41,15 @@ export default function About() {
.replace("github.com", "api.github.com/repos")
.replace(/.git$/, "/releases/latest");
try {
const res = await fetch(endpoint, {
const req = await fetch(endpoint, {
headers: {
Accept: "application/vnd.github.v3+json",
},
});
const json = await res.json();
const res = await req.json();
if (json.tag_name > "v" + version) {
setLatestUpdate(json);
if (res.tag_name > "v" + version) {
setLatestUpdate(res);
setCheckState(null);
} else {
setCheckState("NO_UPDATE");

View File

@@ -4,17 +4,19 @@ import InlineOpenInNewIcon from "components/InlineOpenInNewIcon";
import { IProjectSettingsChildProps } from "pages/Settings/ProjectSettings";
import WIKI_LINKS from "constants/wikiLinks";
import { name, repository } from "@root/package.json";
import { name } from "@root/package.json";
import { runRepoUrl } from "constants/runRoutes";
export default function CloudRun({
export default function rowyRun({
settings,
updateSettings,
}: IProjectSettingsChildProps) {
return (
<>
<Typography>
{name} Run is a Cloud Run instance that deploys this projects Cloud
Functions.{" "}
{name} Run is a Cloud Run instance that provides back-end functionality,
such as table action scripts, user management, and easy Cloud Function
deployment.{" "}
<Link
href={WIKI_LINKS.functions}
target="_blank"
@@ -41,18 +43,15 @@ export default function CloudRun({
<Grid item>
<LoadingButton
href={`https://deploy.cloud.run/?git_repo=${repository.url
.split("/")
.slice(0, -1)
.join("/")}/FunctionsBuilder.git`}
href={`https://deploy.cloud.run/?git_repo=${runRepoUrl}.git`}
target="_blank"
rel="noopener noreferrer"
loading={
settings.cloudRunDeployStatus === "BUILDING" ||
settings.cloudRunDeployStatus === "COMPLETE"
settings.rowyRunDeployStatus === "BUILDING" ||
settings.rowyRunDeployStatus === "COMPLETE"
}
loadingIndicator={
settings.cloudRunDeployStatus === "COMPLETE"
settings.rowyRunDeployStatus === "COMPLETE"
? "Deployed"
: undefined
}
@@ -65,9 +64,9 @@ export default function CloudRun({
<TextField
label="Cloud Run Instance URL"
id="cloudRunUrl"
id="rowyRunUrl"
defaultValue={settings.rowyRunUrl}
onChange={(e) => updateSettings({ cloudRunUrl: e.target.value })}
onChange={(e) => updateSettings({ rowyRunUrl: e.target.value })}
fullWidth
placeholder="https://<id>.run.app"
type="url"

View File

@@ -0,0 +1,44 @@
import { Stack, CircularProgress, Typography } from "@mui/material";
import CheckIcon from "@mui/icons-material/Check";
import ArrowIcon from "@mui/icons-material/ArrowForward";
export interface ISetupItemProps {
status: "complete" | "loading" | "incomplete";
title: React.ReactNode;
children?: React.ReactNode;
}
export default function SetupItem({
status,
title,
children,
}: ISetupItemProps) {
return (
<Stack
direction="row"
spacing={2}
alignItems="flex-start"
aria-busy={status === "loading"}
aria-describedby={status === "loading" ? "progress" : undefined}
>
{status === "complete" ? (
<CheckIcon aria-label="Item complete" color="action" />
) : status === "loading" ? (
<CircularProgress
id="progress"
size={20}
thickness={5}
sx={{ m: 0.25 }}
/>
) : (
<ArrowIcon aria-label="Item" color="primary" />
)}
<Stack spacing={2} alignItems="flex-start">
<Typography variant="inherit">{title}</Typography>
{children}
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,38 @@
import { ISetupStepBodyProps } from "pages/Setup";
import { FormControlLabel, Checkbox, Typography, Link } from "@mui/material";
import OpenInNewIcon from "components/InlineOpenInNewIcon";
export default function Welcome({
completion,
setCompletion,
}: ISetupStepBodyProps) {
return (
<FormControlLabel
control={
<Checkbox
checked={completion.welcome}
onChange={(e) =>
setCompletion((c) => ({ ...c, welcome: e.target.checked }))
}
/>
}
label={
<>
<Typography sx={{ mt: 1.25, mb: 0.5 }}>
I agree to the terms and conditions
</Typography>
<Link display="block" variant="body2" color="text.secondary">
Read the simple English version
<OpenInNewIcon />
</Link>
<Link display="block" variant="body2" color="text.secondary">
Read the full terms and conditions
<OpenInNewIcon />
</Link>
</>
}
sx={{ pr: 1, textAlign: "left", alignItems: "flex-start", p: 0, m: 0 }}
/>
);
}

View File

@@ -0,0 +1,175 @@
import { useState, useEffect } from "react";
import { useLocation, useHistory } from "react-router-dom";
import queryString from "query-string";
import { ISetupStepBodyProps } from "pages/Setup";
import { Button, Typography, Stack, TextField } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import SetupItem from "./SetupItem";
import { runRepoUrl, RunRoutes } from "constants/runRoutes";
export default function Step1RowyRun({
completion,
setCompletion,
rowyRunUrl: paramsRowyRunUrl,
}: ISetupStepBodyProps) {
const history = useHistory();
const { pathname } = useLocation();
const [isValidRowyRunUrl, setIsValidRowyRunUrl] = useState(false);
const [isLatestVersion, setIsLatestVersion] = useState(false);
const [rowyRunUrl, setRowyRunUrl] = useState(paramsRowyRunUrl);
const [latestVersion, setLatestVersion] = useState("");
const [verificationStatus, setVerificationStatus] = useState<
"idle" | "loading" | "pass" | "fail"
>("idle");
const verifyRowyRunUrl = async () => {
setVerificationStatus("loading");
try {
const req = await fetch(rowyRunUrl + RunRoutes.version.path, {
method: RunRoutes.version.method,
});
if (!req.ok) throw new Error("Request failed");
const res = await req.json();
if (!res.version) throw new Error("Invalid response");
// https://docs.github.com/en/rest/reference/repos#get-the-latest-release
const endpoint =
runRepoUrl.replace("github.com", "api.github.com/repos") +
"/releases/latest";
const latestVersionReq = await fetch(endpoint, {
headers: { Accept: "application/vnd.github.v3+json" },
});
const latestVersion = await latestVersionReq.json();
if (!latestVersion.tag_name) throw new Error("No releases");
if (latestVersion.tag_name > "v" + res.version) {
setVerificationStatus("pass");
setIsLatestVersion(false);
setLatestVersion(latestVersion.tag_name);
} else {
setVerificationStatus("pass");
setIsLatestVersion(true);
setLatestVersion("v" + res.version);
setCompletion((c) => ({ ...c, rowyRun: true }));
}
setIsValidRowyRunUrl(true);
history.replace({
pathname,
search: queryString.stringify({ rowyRunUrl }),
});
} catch (e: any) {
console.error(`Failed to verify Rowy Run URL: ${e.message}`);
setVerificationStatus("fail");
}
};
useEffect(() => {
if (!isValidRowyRunUrl && paramsRowyRunUrl) console.log(paramsRowyRunUrl);
}, [paramsRowyRunUrl, isValidRowyRunUrl]);
const deployButton = window.location.hostname.includes("rowy.app") ? (
<Button
href={`https://deploy.cloud.run/?git_repo=${runRepoUrl}.git`}
target="_blank"
rel="noopener noreferrer"
endIcon={<OpenInNewIcon />}
>
One-Click Deploy
</Button>
) : (
<Button
href={runRepoUrl}
target="_blank"
rel="noopener noreferrer"
endIcon={<OpenInNewIcon />}
>
Deploy Instructions
</Button>
);
return (
<>
<SetupItem
status={isValidRowyRunUrl ? "complete" : "incomplete"}
title={
isValidRowyRunUrl
? `Rowy Run is set up at: ${rowyRunUrl}`
: "Deploy Rowy Run to your GCP project."
}
>
{!isValidRowyRunUrl && (
<>
{deployButton}
<div>
<Typography variant="inherit" gutterBottom>
Then paste the Rowy Run instance URL below:
</Typography>
<Stack
direction="row"
spacing={1}
alignItems="center"
style={{ width: "100%" }}
>
<TextField
id="rowyRunUrl"
label="Rowy Run Instance URL"
placeholder="https://*.run.app"
value={rowyRunUrl}
onChange={(e) => setRowyRunUrl(e.target.value)}
type="url"
autoComplete="url"
fullWidth
error={verificationStatus === "fail"}
helperText={
verificationStatus === "fail" ? "Invalid URL" : " "
}
/>
<LoadingButton
variant="contained"
color="primary"
loading={verificationStatus === "loading"}
onClick={verifyRowyRunUrl}
>
Verify
</LoadingButton>
</Stack>
</div>
</>
)}
</SetupItem>
{isValidRowyRunUrl && (
<SetupItem
status={isLatestVersion ? "complete" : "incomplete"}
title={
isLatestVersion
? `Rowy Run is up to date: ${latestVersion}`
: `Update your Rowy Run instance. Latest version: ${latestVersion}`
}
>
{!isLatestVersion && (
<Stack direction="row" spacing={1} alignItems="center">
{deployButton}
<LoadingButton
variant="contained"
color="primary"
loading={verificationStatus === "loading"}
onClick={verifyRowyRunUrl}
>
Verify
</LoadingButton>
</Stack>
)}
</SetupItem>
)}
</>
);
}

View File

@@ -8,13 +8,9 @@ export enum routes {
setup = "/setup",
table = "/table",
tableGroup = "/tableGroup",
tableWithId = "/table/:id",
tableGroup = "/tableGroup",
tableGroupWithId = "/tableGroup/:id",
grid = "/grid",
gridWithId = "/grid/:id",
editor = "/editor",
settings = "/settings",
userSettings = "/settings/user",

View File

@@ -1,3 +1,5 @@
export const runRepoUrl = "https://github.com/rowyio/rowyRun";
export type RunRoute = {
path: string;
method: "POST" | "GET";

View File

@@ -1,29 +1,190 @@
import { useRouteMatch } from "react-router-dom";
import { useState, useEffect } from "react";
import { use100vh } from "react-div-100vh";
import { SwitchTransition } from "react-transition-group";
import { useLocation, Link } from "react-router-dom";
import queryString from "query-string";
import {
useMediaQuery,
Paper,
Stepper,
Step,
StepLabel,
StepButton,
MobileStepper,
IconButton,
Typography,
Stack,
DialogActions,
Button,
Tooltip,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import BrandedBackground from "assets/BrandedBackground";
import Logo from "assets/Logo";
import ScrollableDialogContent from "components/Modal/ScrollableDialogContent";
import { SlideTransition } from "components/Modal/SlideTransition";
import Step0Welcome from "@src/components/Setup/Step0Welcome";
import Step1RowyRun from "@src/components/Setup/Step1RowyRun";
import { useAppContext } from "contexts/AppContext";
import { name } from "@root/package.json";
import routes from "constants/routes";
export interface ISetupStep {
id: string;
layout?: "centered" | "step";
shortTitle: string;
title: React.ReactNode;
description: React.ReactNode;
body: React.ReactNode;
actions?: React.ReactNode;
}
export interface ISetupStepBodyProps {
completion: Record<string, boolean>;
setCompletion: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
checkAllSteps: () => void;
rowyRunUrl: string;
}
export default function SetupPage() {
const { params } = useRouteMatch<{ step: string }>();
const { projectId } = useAppContext();
const fullScreenHeight = use100vh() ?? 0;
const isMobile = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
const { search } = useLocation();
const params = queryString.parse(search);
const rowyRunUrl = decodeURIComponent((params.rowyRunUrl as string) || "");
const [stepId, setStepId] = useState("welcome");
const [completion, setCompletion] = useState<Record<string, boolean>>({
welcome: false,
rowyRun: false,
serviceAccount: false,
signIn: false,
rules: false,
migrate: false,
});
const checkAllSteps = () => {};
useEffect(() => {
if (rowyRunUrl) checkAllSteps();
}, [rowyRunUrl]);
const stepProps = { completion, setCompletion, checkAllSteps, rowyRunUrl };
const steps: ISetupStep[] = [
{
id: "welcome",
layout: "centered",
shortTitle: "Welcome",
title: `Welcome to ${name}`,
description: (
<>
<Typography variant="body1" gutterBottom>
Get up and running in around 5 minutes.
</Typography>
<Typography variant="body1" paragraph>
Youll easily set up backend functionality, Firestore Rules, and
user management.
</Typography>
<Typography variant="body1">
Youll set up the project: <b>{projectId}</b>
</Typography>
</>
),
body: <Step0Welcome {...stepProps} />,
actions: completion.welcome ? (
<Button variant="contained" color="primary" type="submit">
Get Started
</Button>
) : (
<Tooltip title="Please accept the terms and conditions">
<div>
<Button variant="contained" color="primary" disabled>
Get Started
</Button>
</div>
</Tooltip>
),
},
{
id: "rowyRun",
shortTitle: `${name} Run`,
title: `Set Up ${name} Run`,
description: `${name} Run is a Google Cloud Run instance that provides back-end functionality, such as table action scripts, user management, and easy Cloud Function deployment.`,
body: <Step1RowyRun {...stepProps} />,
},
{
id: "serviceAccount",
shortTitle: `Service Account`,
title: `Set Up Service Account`,
description: `${name} Run is a Google Cloud Run instance that provides back-end functionality, such as table action scripts, user management, and easy Cloud Functions deployment. Learn more`,
body: `x`,
},
{
id: "signIn",
shortTitle: `Sign In`,
title: `Sign In as the Project Owner`,
description: `${name} Run is a Google Cloud Run instance that provides back-end functionality, such as table action scripts, user management, and easy Cloud Functions deployment. Learn more`,
body: `x`,
},
{
id: "rules",
shortTitle: `Rules`,
title: `Set Up Firestore Rules`,
description: `${name} Run is a Google Cloud Run instance that provides back-end functionality, such as table action scripts, user management, and easy Cloud Functions deployment. Learn more`,
body: `x`,
},
{
id: "migrate",
shortTitle: `Migrate`,
title: `Migrate to ${name}`,
description: `${name} Run is a Google Cloud Run instance that provides back-end functionality, such as table action scripts, user management, and easy Cloud Functions deployment. Learn more`,
body: `x`,
},
{
id: "finish",
layout: "centered",
shortTitle: `Finish`,
title: `Youre all set up!`,
description: `You can now create a table from your Firestore collections or continue to ${name}.`,
body: <div>x</div>,
actions: (
<>
<Button variant="contained" color="primary">
Create Table
</Button>
<Button component={Link} to={routes.home} sx={{ ml: 1 }}>
Continue to {name}
</Button>
</>
),
},
];
const step =
steps.find((step) => step.id === (stepId || steps[0].id)) ?? steps[0];
const stepIndex = steps.findIndex(
(step) => step.id === (stepId || steps[0].id)
);
const listedSteps = steps.filter((step) => step.layout !== "centered");
const handleContinue = () => {
let nextIncompleteStepIndex = stepIndex + 1;
while (completion[steps[nextIncompleteStepIndex]?.id]) {
console.log("iteration", steps[nextIncompleteStepIndex]?.id);
nextIncompleteStepIndex++;
}
setStepId(steps[nextIncompleteStepIndex].id);
};
return (
<>
<BrandedBackground />
@@ -49,23 +210,16 @@ export default function SetupPage() {
"& > *": { px: { xs: 2, sm: 4 } },
display: "flex",
flexDirection: "column",
"& .MuiTypography-inherit, & .MuiDialogContent-root": {
typography: "body1",
},
}}
>
{isMobile ? (
<MobileStepper
variant="dots"
steps={4}
backButton={null}
nextButton={null}
position="static"
sx={{
background: "none",
m: 1,
mt: 1.25,
}}
/>
) : (
{stepId === "welcome" ? null : !isMobile ? (
<Stepper
activeStep={stepIndex - 1}
nonLinear
sx={{
mt: 2.5,
mb: 3,
@@ -74,41 +228,152 @@ export default function SetupPage() {
userSelect: "none",
}}
>
<Step>
<StepLabel>Rowy Run</StepLabel>
</Step>
<Step>
<StepLabel>Service Account</StepLabel>
</Step>
<Step>
<StepLabel>Sign In</StepLabel>
</Step>
<Step>
<StepLabel>Firestore Rules</StepLabel>
</Step>
{listedSteps.map(({ id, shortTitle }, i) => (
<Step key={id} completed={completion[id]}>
<StepButton
onClick={() => setStepId(id)}
disabled={i > 0 && !completion[listedSteps[i - 1]?.id]}
sx={{ py: 2, my: -2, borderRadius: 1 }}
>
{shortTitle}
</StepButton>
</Step>
))}
</Stepper>
) : (
<MobileStepper
variant="dots"
steps={listedSteps.length}
activeStep={stepIndex - 1}
backButton={
<IconButton
aria-label="Previous step"
disabled={stepIndex === 0}
onClick={() => setStepId(steps[stepIndex - 1].id)}
>
<ChevronLeftIcon />
</IconButton>
}
nextButton={
<IconButton
aria-label="Next step"
disabled={!completion[stepId]}
onClick={() => setStepId(steps[stepIndex + 1].id)}
>
<ChevronRightIcon />
</IconButton>
}
position="static"
sx={{
background: "none",
p: 0,
"& .MuiMobileStepper-dot": { mx: 0.5 },
}}
/>
)}
<Typography
variant="h4"
component="h1"
sx={{ mb: 1, typography: { xs: "h5", md: "h4" } }}
{step.layout === "centered" ? (
<ScrollableDialogContent disableTopDivider>
<Stack
alignItems="center"
justifyContent="center"
spacing={3}
sx={{
minHeight: "100%",
maxWidth: 400,
margin: "0 auto",
textAlign: "center",
py: 3,
}}
>
{stepId === "welcome" && (
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={50}>
<Logo size={2} />
</SlideTransition>
</SwitchTransition>
)}
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={100}>
<div>
<Typography
variant="h4"
component="h1"
sx={{ mb: 1, typography: { xs: "h5", md: "h4" } }}
>
{step.title}
</Typography>
<Typography variant="inherit" component="div">
{step.description}
</Typography>
</div>
</SlideTransition>
</SwitchTransition>
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={150}>
<Stack spacing={4} alignItems="center">
{step.body}
</Stack>
</SlideTransition>
</SwitchTransition>
</Stack>
</ScrollableDialogContent>
) : (
<>
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={50}>
<Typography
variant="h4"
component="h1"
sx={{ mb: 1, typography: { xs: "h5", md: "h4" } }}
>
{step.title}
</Typography>
</SlideTransition>
</SwitchTransition>
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={100}>
<ScrollableDialogContent disableTopDivider>
<Stack spacing={4}>
<Typography variant="body1" style={{ maxWidth: "70ch" }}>
{step.description}
</Typography>
{step.body}
</Stack>
</ScrollableDialogContent>
</SlideTransition>
</SwitchTransition>
</>
)}
<form
onSubmit={(e) => {
e.preventDefault();
try {
handleContinue();
} catch (e: any) {
throw new Error(e.message);
}
return false;
}}
>
Set Up Rowy Run
</Typography>
<ScrollableDialogContent>
<Typography variant="body1">
Rowy Run is a Cloud Run instance.
</Typography>
<div style={{ height: "100vh" }}>content</div>
</ScrollableDialogContent>
<DialogActions>
<Button variant="contained" color="primary">
Get Started
</Button>
</DialogActions>
<DialogActions>
{step.actions ?? (
<Button
variant="contained"
color="primary"
type="submit"
disabled={!completion[stepId]}
>
Continue
</Button>
)}
</DialogActions>
</form>
</Paper>
</>
);

View File

@@ -70,7 +70,7 @@ export default function CheckboxIcon() {
>
<svg viewBox="0 0 18 18">
<polyline
stroke-width="2"
strokeWidth="2"
points="2.705 8.29 7 12.585 15.295 4.29"
fill="none"
className="tick"

View File

@@ -69,7 +69,7 @@ export default function CheckboxIndeterminateIcon() {
}}
>
<svg viewBox="0 0 18 18">
<line x1="3" y1="9" x2="15" y2="9" stroke-width="2" className="tick" />
<line x1="3" y1="9" x2="15" y2="9" strokeWidth="2" className="tick" />
</svg>
</Box>
);

View File

@@ -810,14 +810,15 @@ export const components = (theme: Theme): ThemeOptions => {
styleOverrides: {
root: {
color: theme.palette.divider,
"&.Mui-completed:not(.Mui-active)": {
color: theme.palette.text.disabled,
},
},
text: {
fontWeight: theme.typography.fontWeightBold,
fill: theme.palette.text.secondary,
".Mui-active &": {
fill: theme.palette.primary.contrastText,
},
".Mui-active &": { fill: theme.palette.primary.contrastText },
},
},
},