action settings overhaul with form input support

This commit is contained in:
Sidney Alcantara
2021-11-12 15:42:34 +11:00
parent 31b2f74988
commit 0709c59417
8 changed files with 590 additions and 172 deletions

View File

@@ -19,7 +19,7 @@
"@mui/lab": "^5.0.0-alpha.50",
"@mui/material": "^5.0.0",
"@mui/styles": "^5.0.0",
"@rowy/form-builder": "^0.3.1",
"@rowy/form-builder": "^0.4.2",
"@rowy/multiselect": "^0.2.3",
"@tinymce/tinymce-react": "^3.12.6",
"algoliasearch": "^4.8.6",

View File

@@ -63,6 +63,7 @@ export default function CodeEditorHelper({
<Button
size="small"
color="primary"
target="_blank"
rel="noopener noreferrer"
href={docLink}

View File

@@ -4,7 +4,7 @@ export const InlineOpenInNewIcon = styled("span")(() => ({
position: "relative",
width: "1em",
height: "1em",
marginLeft: "0.25em",
marginLeft: "0.25ch",
display: "inline-block",
verticalAlign: "baseline",

View File

@@ -10,7 +10,6 @@ import CircularProgressOptical from "@src/components/CircularProgressOptical";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { functions } from "@src/firebase";
import { formatPath } from "@src/utils/fns";
import { useConfirmation } from "@src/components/ConfirmationDialog";
import { useActionParams } from "./FormDialog/Context";
import { runRoutes } from "@src/constants/runRoutes";
@@ -112,9 +111,15 @@ export default function ActionFab({
: "redo"
: "run";
const needsParams = Array.isArray(config.params) && config.params.length > 0;
const needsParams =
config.friction === "params" &&
Array.isArray(config.params) &&
config.params.length > 0;
const needsConfirmation =
typeof config.confirmation === "string" && config.confirmation !== "";
(!config.friction || config.friction === "confirmation") &&
typeof config.confirmation === "string" &&
config.confirmation !== "";
return (
<Fab
onClick={

View File

@@ -15,10 +15,10 @@ const yupOrderKeys = (acc, currKey) => {
else return [...acc, currKey];
};
const validationCompiler = (validation) =>
Object.keys(validation)
.reduce(yupOrderKeys, [])
.reduce(yupReducer(validation), yup);
// const validationCompiler = (validation) =>
// Object.keys(validation)
// .reduce(yupOrderKeys, [])
// .reduce(yupReducer(validation), yup);
export default function ParamsDialog({
column,
@@ -42,10 +42,6 @@ export default function ParamsDialog({
validation:{array:null,required:'needs to specific the cohort to new cohort',max:[1,'only one cohort is allowed']},
}]
*/
const fields = column.config.params.map((field) => ({
...field,
validation: field.validation ? validationCompiler(field.validation) : null,
}));
if (!open) return null;
@@ -53,7 +49,7 @@ export default function ParamsDialog({
<FormDialog
onClose={handleClose}
title={`${column.name as string}`}
fields={fields}
fields={column.config.params}
values={{}}
onSubmit={handleRun}
SubmitButtonProps={{ children: "Run" }}

View File

@@ -0,0 +1,102 @@
import { useState } from "react";
import { useSnackbar } from "notistack";
import { FieldType } from "@rowy/form-builder";
import { Button, Menu, MenuItem } from "@mui/material";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
const FORM_FIELD_SNIPPETS = [
{
label: "Text field",
value: {
type: FieldType.shortText,
name: "username",
label: "Username",
placeholder: "foo_bar",
required: true,
maxCharacters: 15,
validation: [
["min", 3, "Must be at least 3 characters"],
{
0: "notOneOf",
1: ["admin", "administrator"],
2: "Reserved username",
},
],
},
},
{
label: "Single select",
value: {
type: FieldType.singleSelect,
name: "team",
label: "Team",
required: true,
options: ["Blue", "Orange"],
},
},
{
label: "Multi select",
value: {
type: FieldType.multiSelect,
name: "roles",
label: "Roles",
required: true,
options: ["ADMIN", "EDITOR", "VIEWER"],
},
},
];
export default function FormFieldSnippets() {
const { enqueueSnackbar } = useSnackbar();
const [snippetMenuAnchor, setSnippetMenuAnchor] = useState<any | null>(null);
return (
<>
<Button
size="small"
endIcon={<ArrowDropDownIcon />}
onClick={(e) => setSnippetMenuAnchor(e.currentTarget)}
id="snippet-button"
aria-controls="snippet-menu"
aria-haspopup="true"
aria-expanded={!!snippetMenuAnchor ? "true" : "false"}
>
Copy snippet
</Button>
<Menu
id="snippet-menu"
anchorEl={snippetMenuAnchor}
open={!!snippetMenuAnchor}
onClose={() => setSnippetMenuAnchor(null)}
MenuListProps={{ "aria-labelledby": "snippet-button" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
transformOrigin={{ horizontal: "right", vertical: "top" }}
>
{FORM_FIELD_SNIPPETS.map((snippet) => (
<MenuItem
key={snippet.label}
children={snippet.label}
onClick={() => {
// Write validation as nested array above, but must be converted
// to [ { 0: …, 1: …, … } ] since Firestore doesn't support
// nested arrays
const sanitized: any = snippet.value;
if (Array.isArray(snippet.value.validation))
sanitized.validation = snippet.value.validation.reduce(
(a, c, i) => ({ ...a, [i]: c }),
{}
);
navigator.clipboard.writeText(
JSON.stringify(sanitized, undefined, 2)
);
enqueueSnackbar("Copied to clipboard");
setSnippetMenuAnchor(null);
}}
/>
))}
</Menu>
</>
);
}

View File

@@ -1,16 +1,36 @@
import { lazy, Suspense } from "react";
import { lazy, Suspense, useState } from "react";
import _get from "lodash/get";
import stringify from "json-stable-stringify-without-jsonify";
import {
Typography,
Stepper,
Step,
StepButton,
StepContent,
Stack,
Grid,
TextField,
Switch,
FormControlLabel,
Divider,
FormControl,
FormLabel,
FormControlLabel,
RadioGroup,
Radio,
Typography,
InputLabel,
Link,
Checkbox,
FormHelperText,
} from "@mui/material";
import ExpandIcon from "@mui/icons-material/KeyboardArrowDown";
import MultiSelect from "@rowy/multiselect";
import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { InputLabel } from "@mui/material";
import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import FormFieldSnippets from "./FormFieldSnippets";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { WIKI_LINKS } from "@src/constants/externalLinks";
const CodeEditor = lazy(
@@ -20,165 +40,459 @@ const CodeEditor = lazy(
const Settings = ({ config, onChange }) => {
const { tableState, roles } = useProjectContext();
const [activeStep, setActiveStep] = useState(0);
const columnOptions = Object.values(tableState?.columns ?? {}).map((c) => ({
label: c.name,
value: c.key,
}));
const formattedParamsJson = stringify(
Array.isArray(config.params) ? config.params : [],
{ space: 2 }
);
const [codeValid, setCodeValid] = useState(true);
const scriptExtraLibs = [
[
"declare class ref {",
" /**",
" * Reference object of the row running the action script",
" */",
"static id:string",
"static path:string",
"static parentId:string",
"static tablePath:string",
"}",
].join("\n"),
[
"declare class actionParams {",
" /**",
" * actionParams are provided by dialog popup form",
" */",
(config.params ?? []).filter(Boolean).map((param) => {
const validationKeys = Object.keys(param.validation ?? {});
if (validationKeys.includes("string")) {
return `static ${param.name}: string`;
} else if (validationKeys.includes("array")) {
return `static ${param.name}: any[]`;
} else return `static ${param.name}: any`;
}),
"}",
].join("\n"),
];
// Backwards-compatibility: previously user could set `confirmation` without
// having to set `friction: confirmation`
const showConfirmationField =
config.friction === "confirmation" ||
(!config.friction &&
typeof config.confirmation === "string" &&
config.confirmation !== "");
return (
<>
<Typography variant="overline">Allowed roles</Typography>
<Typography variant="body2">
Authenticated user must have at least one of these to run the script
</Typography>
<MultiSelect
label="Allowed roles"
options={roles ?? []}
value={config.requiredRoles ?? []}
onChange={onChange("requiredRoles")}
/>
<Stepper
nonLinear
activeStep={activeStep}
orientation="vertical"
sx={{
mt: 0,
<Typography variant="overline">Required fields</Typography>
<Typography variant="body2">
All of the selected fields must have a value for the script to run
</Typography>
"& .MuiStepLabel-root": { width: "100%" },
"& .MuiStepLabel-label": {
display: "flex",
width: "100%",
typography: "subtitle2",
"&.Mui-active": { typography: "subtitle2" },
},
"& .MuiStepLabel-label svg": {
display: "block",
marginLeft: "auto",
my: ((24 - 18) / 2 / 8) * -1,
transition: (theme) => theme.transitions.create("transform"),
},
"& .Mui-active svg": {
transform: "rotate(180deg)",
},
}}
>
<Step>
<StepButton onClick={() => setActiveStep(0)}>
Requirements
<ExpandIcon />
</StepButton>
<MultiSelect
label="Required fields"
options={columnOptions}
value={config.requiredFields ?? []}
onChange={onChange("requiredFields")}
/>
<Divider />
<Typography variant="overline">Confirmation template</Typography>
<Typography variant="body2">
The action button will not ask for confirmation if this is left empty
</Typography>
<TextField
label="Confirmation template"
placeholder="Are sure you want to invest {{stockName}}?"
value={config.confirmation}
onChange={(e) => {
onChange("confirmation")(e.target.value);
}}
fullWidth
/>
<FormControlLabel
control={
<Switch
checked={config.isActionScript}
onChange={() =>
onChange("isActionScript")(!Boolean(config.isActionScript))
}
name="actionScript"
/>
}
label="Set as an action script"
/>
{!Boolean(config.isActionScript) ? (
<TextField
label="Callable name"
name="callableName"
value={config.callableName}
fullWidth
onChange={(e) => {
onChange("callableName")(e.target.value);
}}
/>
) : (
<>
<InputLabel>Action script</InputLabel>
<CodeEditorHelper
docLink={WIKI_LINKS.fieldTypesAction}
additionalVariables={[]}
/>
<Suspense fallback={<FieldSkeleton height={300} />}>
<CodeEditor
minHeight={300}
value={config.script}
extraLibs={[
[
"declare class ref {",
" /**",
" * Reference object of the row running the action script",
" */",
"static id:string",
"static path:string",
"static parentId:string",
"static tablePath:string",
"}",
].join("\n"),
[
"declare class actionParams {",
" /**",
" * actionParams are provided by dialog popup form",
" */",
(config.params ?? []).map((param) => {
const validationKeys = Object.keys(param.validation);
if (validationKeys.includes("string")) {
return `static ${param.name}:string`;
} else if (validationKeys.includes("array")) {
return `static ${param.name}:any[]`;
} else return `static ${param.name}:any`;
}),
"}",
].join("\n"),
]}
onChange={onChange("script")}
/>
</Suspense>
<FormControlLabel
control={
<Switch
checked={config.redo?.enabled}
onChange={() =>
onChange("redo.enabled")(!Boolean(config.redo?.enabled))
}
name="redo toggle"
/>
}
label="User can redo (re-runs the same script)"
/>
<FormControlLabel
control={
<Switch
checked={config.undo?.enabled}
onChange={() =>
onChange("undo.enabled")(!Boolean(config.undo?.enabled))
}
name="undo toggle"
/>
}
label="User can undo"
/>
{config["undo.enabled"] && (
<>
<Typography variant="overline">
Undo confirmation template
</Typography>
<TextField
label="Template"
placeholder="are you sure you want to sell your stocks in {{stockName}}"
value={config["undo.confirmation"]}
onChange={(e) => {
onChange("undo.confirmation")(e.target.value);
<StepContent>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<MultiSelect
label="Required roles"
options={roles ?? []}
value={config.requiredRoles ?? []}
onChange={onChange("requiredRoles")}
TextFieldProps={{
id: "requiredRoles",
helperText:
"The user must have at least one of these roles to run the script",
}}
fullWidth
/>
<Typography variant="overline">Undo action script</Typography>
<Suspense fallback={<FieldSkeleton height={300} />}>
<CodeEditor
minHeight={300}
value={config["undo.script"]}
onChange={onChange("undo.script")}
</Grid>
<Grid item xs={12} sm={6}>
<MultiSelect
label="Required fields"
options={columnOptions}
value={config.requiredFields ?? []}
onChange={onChange("requiredFields")}
TextFieldProps={{
id: "requiredFields",
helperText:
"All the selected fields must have a value for the script to run",
}}
/>
</Grid>
</Grid>
</StepContent>
</Step>
<Step>
<StepButton onClick={() => setActiveStep(1)}>
Confirmation
<ExpandIcon />
</StepButton>
<StepContent>
<Stack spacing={3}>
<FormControl component="fieldset">
<FormLabel component="legend">
Clicking the action button will:
</FormLabel>
<RadioGroup
aria-label="Action button friction"
name="friction"
defaultValue={
typeof config.confirmation === "string" &&
config.confirmation !== ""
? "confirmation"
: "none"
}
value={config.friction}
onChange={(e) => onChange("friction")(e.target.value)}
>
<FormControlLabel
value="none"
control={<Radio />}
label="Run the action immediately"
/>
</Suspense>
</>
)}
</>
<FormControlLabel
value="confirmation"
control={<Radio />}
label="Ask the user for confirmation"
/>
<FormControlLabel
value="params"
control={<Radio />}
label={
<>
<Typography variant="inherit">
Ask the user for input in a form (Alpha)
</Typography>
<Typography variant="caption" color="text.secondary">
This feature is currently undocumented and is subject to
change in future minor versions
</Typography>
</>
}
/>
</RadioGroup>
</FormControl>
{showConfirmationField && (
<TextField
id="confirmation"
label="Confirmation template"
placeholder="Are sure you want to invest {{stockName}}?"
value={config.confirmation}
onChange={(e) => onChange("confirmation")(e.target.value)}
fullWidth
helperText="The action button will not ask for confirmation if this is left empty"
/>
)}
{config.friction === "params" && (
<FormControl>
<Grid container spacing={1} sx={{ mb: 0.5 }}>
<Grid item xs>
<InputLabel variant="filled">Form fields</InputLabel>
</Grid>
<Grid item>
<FormFieldSnippets />
</Grid>
</Grid>
<Suspense fallback={<FieldSkeleton height={300} />}>
<CodeEditor
minHeight={200}
defaultLanguage="json"
value={formattedParamsJson}
onChange={(v) => {
try {
if (v) {
const parsed = JSON.parse(v);
onChange("params")(parsed);
}
} catch (e) {
console.log(`Failed to parse JSON: ${e}`);
setCodeValid(false);
}
}}
onValidStatusUpdate={({ isValid }) => setCodeValid(isValid)}
error={!codeValid}
/>
</Suspense>
{!codeValid && (
<FormHelperText error variant="filled">
Invalid JSON
</FormHelperText>
)}
</FormControl>
)}
</Stack>
</StepContent>
</Step>
<Step>
<StepButton onClick={() => setActiveStep(2)}>
Action
<ExpandIcon />
</StepButton>
<StepContent>
<Stack spacing={3}>
<FormControl component="fieldset">
<FormLabel component="legend">
Clicking the action button will run a:
</FormLabel>
<RadioGroup
aria-label="Action will run"
name="isActionScript"
value={config.isActionScript ? "actionScript" : "cloudFunction"}
onChange={(e) =>
onChange("isActionScript")(e.target.value === "actionScript")
}
>
<FormControlLabel
value="actionScript"
control={<Radio />}
label={
<>
<Typography variant="inherit">Script</Typography>
<Typography variant="caption" color="textSecondary">
Write JavaScript code below that will be executed by
Rowy Run.{" "}
<Link
href={WIKI_LINKS.rowyRun}
target="_blank"
rel="noopener noreferrer"
>
Requires Rowy Run setup
<InlineOpenInNewIcon />
</Link>
</Typography>
</>
}
/>
<FormControlLabel
value="cloudFunction"
control={<Radio />}
label={
<>
<Typography variant="inherit">Callable</Typography>
<Typography variant="caption" color="textSecondary">
A{" "}
<Link
href="https://firebase.google.com/docs/functions/callable"
target="_blank"
rel="noopener noreferrer"
>
callable function
<InlineOpenInNewIcon />
</Link>{" "}
youve deployed on your Firestore or Google Cloud
project
</Typography>
</>
}
/>
</RadioGroup>
</FormControl>
{!config.isActionScript ? (
<TextField
id="callableName"
label="Callable name"
name="callableName"
value={config.callableName}
fullWidth
onChange={(e) => onChange("callableName")(e.target.value)}
helperText={
<>
Write the name of the callable function youve deployed to
your project.{" "}
<Link
href={`https://console.firebase.google.com/project/rowyio/functions/list`}
target="_blank"
rel="noopener noreferrer"
>
View your callable functions
<InlineOpenInNewIcon />
</Link>
<br />
Your callable function must be compatible with Rowy Action
columns.{" "}
<Link
href={WIKI_LINKS.fieldTypesAction + "#callable"}
target="_blank"
rel="noopener noreferrer"
>
View requirements
<InlineOpenInNewIcon />
</Link>
</>
}
/>
) : (
<>
<FormControl>
<InputLabel variant="filled">Action script</InputLabel>
<Suspense fallback={<FieldSkeleton height={300} />}>
<CodeEditor
minHeight={200}
value={config.script}
onChange={onChange("script")}
extraLibs={scriptExtraLibs}
/>
</Suspense>
<CodeEditorHelper
docLink={WIKI_LINKS.fieldTypesAction + "#script"}
additionalVariables={[]}
/>
</FormControl>
<Grid container>
<Grid item xs={12} sm={6}>
<FormControlLabel
control={
<Checkbox
checked={config.redo?.enabled}
onChange={() =>
onChange("redo.enabled")(
!Boolean(config.redo?.enabled)
)
}
name="redo"
/>
}
label={
<>
<Typography variant="inherit">
User can redo
</Typography>
<Typography variant="caption" color="text.secondary">
Re-runs the script above
</Typography>
</>
}
style={{ marginLeft: -11 }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControlLabel
control={
<Checkbox
checked={config.undo?.enabled}
onChange={() =>
onChange("undo.enabled")(
!Boolean(config.undo?.enabled)
)
}
name="undo"
/>
}
label={
<>
<Typography variant="inherit">
User can undo
</Typography>
<Typography variant="caption" color="text.secondary">
Runs a new script
</Typography>
</>
}
style={{ marginLeft: -11 }}
/>
</Grid>
</Grid>
</>
)}
</Stack>
</StepContent>
</Step>
{config.isActionScript && _get(config, "undo.enabled") && (
<Step>
<StepButton onClick={() => setActiveStep(3)}>
Undo action
<ExpandIcon />
</StepButton>
<StepContent>
<Stack spacing={3}>
{(showConfirmationField ||
!config.friction ||
config.friction === "none") && (
<TextField
id="undo.confirmation"
label="Undo confirmation template"
placeholder="Are you sure you want to sell your stocks in {{stockName}}?"
value={_get(config, "undo.confirmation")}
onChange={(e) => {
onChange("undo.confirmation")(e.target.value);
}}
fullWidth
helperText={
<>
{showConfirmationField &&
"Override the confirmation message above. "}
The action button will not ask for confirmation if this is
left empty{showConfirmationField && "."}
</>
}
/>
)}
<FormControl>
<InputLabel variant="filled">Undo script</InputLabel>
<Suspense fallback={<FieldSkeleton height={300} />}>
<CodeEditor
value={_get(config, "undo.script")}
onChange={onChange("undo.script")}
extraLibs={scriptExtraLibs}
/>
</Suspense>
<CodeEditorHelper
docLink={WIKI_LINKS.fieldTypesAction + "#script"}
additionalVariables={[]}
/>
</FormControl>
</Stack>
</StepContent>
</Step>
)}
</>
</Stepper>
);
};
export default Settings;

View File

@@ -2664,10 +2664,10 @@
estree-walker "^1.0.1"
picomatch "^2.2.2"
"@rowy/form-builder@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@rowy/form-builder/-/form-builder-0.3.1.tgz#7359a05563c99ae4c222fa5336a57ce7a344ae29"
integrity sha512-5IlNtBb6V5VdbpYWhO4P6QIBVqI42SVko15Bmn1YLd0JyoI3wza+seFBK0PgrBUB/OzTEA/hsBN5cyWhqlKt2Q==
"@rowy/form-builder@^0.4.2":
version "0.4.2"
resolved "https://registry.yarnpkg.com/@rowy/form-builder/-/form-builder-0.4.2.tgz#e1693b16af31ed486b7314cd657cd3a1c9c3e937"
integrity sha512-bedChgzyL7BxeQVijaxAXYJqcZtTaCLy4CfqInbsKEB+0OJ3nWi2c9l9d+v8yf6QJ2+3ERyveVfuKq6HAVTQ/g==
dependencies:
"@hookform/resolvers" "^2.6.0"
"@mdi/js" "^5.9.55"