mirror of
https://github.com/rowyio/rowy.git
synced 2026-02-23 19:50:01 +01:00
add new step-based extensions UI
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Stack, Typography, Grid, Tooltip, Chip, Button } from "@mui/material";
|
||||
import { Stack, Typography, Grid, Tooltip, Button } from "@mui/material";
|
||||
import InlineOpenInNewIcon from "components/InlineOpenInNewIcon";
|
||||
|
||||
export interface ICodeEditorHelperProps {
|
||||
docLink: string;
|
||||
additionalVariables?: {
|
||||
@@ -42,24 +43,19 @@ export default function CodeEditorHelper({
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.25}
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
sx={{ mb: 1 }}
|
||||
sx={{ my: 1 }}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
You can access:
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={0.5}>
|
||||
<Grid container spacing={1}>
|
||||
{availableVariables.concat(additionalVariables ?? []).map((v) => (
|
||||
<Grid item key={v.key}>
|
||||
<Tooltip title={v.description}>
|
||||
<Chip label={v.key} size="small" />
|
||||
<code>{v.key}</code>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
))}
|
||||
@@ -18,16 +18,22 @@ import {
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import ExtensionIcon from "assets/icons/Extension";
|
||||
import DuplicateIcon from "@mui/icons-material/ContentCopy";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteForever";
|
||||
import DuplicateIcon from "assets/icons/Copy";
|
||||
import EditIcon from "@mui/icons-material/EditOutlined";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
||||
|
||||
import EmptyState from "components/EmptyState";
|
||||
import { extensionTypes, IExtension, IExtensionType } from "./utils";
|
||||
import {
|
||||
extensionTypes,
|
||||
extensionNames,
|
||||
IExtension,
|
||||
ExtensionType,
|
||||
} from "./utils";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
|
||||
export interface IExtensionListProps {
|
||||
extensions: IExtension[];
|
||||
handleAddExtension: (type: IExtensionType) => void;
|
||||
handleAddExtension: (type: ExtensionType) => void;
|
||||
handleUpdateActive: (index: number, active: boolean) => void;
|
||||
handleDuplicate: (index: number) => void;
|
||||
handleEdit: (index: number) => void;
|
||||
@@ -53,7 +59,7 @@ export default function ExtensionList({
|
||||
setAnchorEl(addButtonRef.current);
|
||||
};
|
||||
|
||||
const handleChooseAddType = (type: IExtensionType) => {
|
||||
const handleChooseAddType = (type: ExtensionType) => {
|
||||
handleClose();
|
||||
handleAddExtension(type);
|
||||
};
|
||||
@@ -95,12 +101,8 @@ export default function ExtensionList({
|
||||
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
||||
>
|
||||
{extensionTypes.map((type) => (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleChooseAddType(type);
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
<MenuItem onClick={() => handleChooseAddType(type)}>
|
||||
{extensionNames[type]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
@@ -132,7 +134,14 @@ export default function ExtensionList({
|
||||
children={
|
||||
<ListItemText
|
||||
primary={extensionObject.name}
|
||||
secondary={extensionObject.type}
|
||||
secondary={extensionNames[extensionObject.type]}
|
||||
primaryTypographyProps={{
|
||||
style: {
|
||||
minHeight: 40,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
secondaryAction={
|
||||
@@ -182,13 +191,15 @@ export default function ExtensionList({
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
Last updated by {extensionObject.lastEditor.displayName}
|
||||
Last updated
|
||||
<br />
|
||||
on{" "}
|
||||
{format(extensionObject.lastEditor.lastUpdate, "PPPP")}
|
||||
by {extensionObject.lastEditor.displayName}
|
||||
<br />
|
||||
at{" "}
|
||||
{format(extensionObject.lastEditor.lastUpdate, "pppp")}
|
||||
{format(
|
||||
extensionObject.lastEditor.lastUpdate,
|
||||
DATE_TIME_FORMAT
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,73 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import _isEqual from "lodash/isEqual";
|
||||
import _upperFirst from "lodash/upperFirst";
|
||||
import useStateRef from "react-usestateref";
|
||||
|
||||
import {
|
||||
styled,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
FormLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
Switch,
|
||||
Stack,
|
||||
Tab,
|
||||
TextField,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Stepper,
|
||||
Step,
|
||||
StepButton,
|
||||
StepContent,
|
||||
Typography,
|
||||
Link,
|
||||
} from "@mui/material";
|
||||
import TabContext from "@mui/lab/TabContext";
|
||||
import TabList from "@mui/lab/TabList";
|
||||
import TabPanel from "@mui/lab/TabPanel";
|
||||
import AddIcon from "@mui/icons-material/AddBox";
|
||||
import DeleteIcon from "@mui/icons-material/RemoveCircle";
|
||||
import ExpandIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import InlineOpenInNewIcon from "components/InlineOpenInNewIcon";
|
||||
|
||||
import Modal, { IModalProps } from "components/Modal";
|
||||
import CodeEditor from "../../editors/CodeEditor";
|
||||
import CodeEditorHelper from "components/CodeEditorHelper";
|
||||
import Step1Triggers from "./Step1Triggers";
|
||||
import Step2RequiredFields from "./Step2RequiredFields";
|
||||
import Step3Conditions from "./Step3Conditions";
|
||||
import Step4Body from "./Step4Body";
|
||||
|
||||
import { useConfirmation } from "components/ConfirmationDialog";
|
||||
import { useProjectContext } from "contexts/ProjectContext";
|
||||
|
||||
import { IExtension, triggerTypes } from "./utils";
|
||||
import { extensionNames, IExtension } from "./utils";
|
||||
import { WIKI_LINKS } from "constants/externalLinks";
|
||||
|
||||
const additionalVariables = [
|
||||
{
|
||||
key: "change",
|
||||
description:
|
||||
"you can pass in field name to change.before.get() or change.after.get() to get changes",
|
||||
},
|
||||
{
|
||||
key: "triggerType",
|
||||
description: "triggerType indicates the type of the extension invocation",
|
||||
},
|
||||
{
|
||||
key: "fieldTypes",
|
||||
description:
|
||||
"fieldTypes is a map of all fields and its corresponding field type",
|
||||
},
|
||||
{
|
||||
key: "extensionConfig",
|
||||
description: "the configuration object of this extension",
|
||||
},
|
||||
];
|
||||
|
||||
const StyledTabPanel = styled(TabPanel)({
|
||||
flexGrow: 1,
|
||||
|
||||
overflowY: "auto",
|
||||
margin: "0 calc(var(--dialog-spacing) * -1) 0 !important",
|
||||
padding: "var(--dialog-spacing) var(--dialog-spacing) 0",
|
||||
|
||||
"&[hidden]": { display: "none" },
|
||||
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
});
|
||||
type StepValidation = Record<"condition" | "extensionBody", boolean>;
|
||||
export interface IExtensionModalStepProps {
|
||||
extensionObject: IExtension;
|
||||
setExtensionObject: React.Dispatch<React.SetStateAction<IExtension>>;
|
||||
validation: StepValidation;
|
||||
setValidation: React.Dispatch<React.SetStateAction<StepValidation>>;
|
||||
validationRef: React.RefObject<StepValidation>;
|
||||
}
|
||||
|
||||
export interface IExtensionModalProps {
|
||||
handleClose: IModalProps["onClose"];
|
||||
@@ -85,46 +54,45 @@ export default function ExtensionModal({
|
||||
extensionObject: initialObject,
|
||||
}: IExtensionModalProps) {
|
||||
const { requestConfirmation } = useConfirmation();
|
||||
|
||||
const [extensionObject, setExtensionObject] =
|
||||
useState<IExtension>(initialObject);
|
||||
const [tab, setTab] = useState("triggersRequirements");
|
||||
const [validation, setValidation, validationRef] = useStateRef({
|
||||
condition: true,
|
||||
extensionBody: true,
|
||||
});
|
||||
const [, setConditionEditorActive, conditionEditorActiveRef] =
|
||||
useStateRef(false);
|
||||
const [, setBodyEditorActive, bodyEditorActiveRef] = useStateRef(false);
|
||||
const { tableState } = useProjectContext();
|
||||
const columns = Object.keys(tableState?.columns ?? {});
|
||||
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
|
||||
const [validation, setValidation, validationRef] =
|
||||
useStateRef<StepValidation>({ condition: true, extensionBody: true });
|
||||
|
||||
const edited = !_isEqual(initialObject, extensionObject);
|
||||
|
||||
const handleAddOrUpdate = () => {
|
||||
switch (mode) {
|
||||
case "add":
|
||||
handleAdd(extensionObject);
|
||||
return;
|
||||
case "update":
|
||||
handleUpdate(extensionObject);
|
||||
return;
|
||||
}
|
||||
if (mode === "add") handleAdd(extensionObject);
|
||||
if (mode === "update") handleUpdate(extensionObject);
|
||||
};
|
||||
|
||||
const stepProps = {
|
||||
extensionObject,
|
||||
setExtensionObject,
|
||||
validation,
|
||||
setValidation,
|
||||
validationRef,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
maxWidth="md"
|
||||
disableBackdropClick
|
||||
disableEscapeKeyDown
|
||||
fullWidth
|
||||
fullHeight
|
||||
title={`${mode === "add" ? "Add" : "Update"} Extension: ${
|
||||
extensionNames[extensionObject.type]
|
||||
}`}
|
||||
sx={{
|
||||
"& .MuiDialogContent-root": {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
"& .MuiPaper-root": {
|
||||
maxWidth: 742 + 20,
|
||||
height: 980,
|
||||
},
|
||||
}}
|
||||
title={`${mode === "add" ? "Add" : "Update"} Extension`}
|
||||
children={
|
||||
<>
|
||||
<Grid
|
||||
@@ -133,7 +101,7 @@ export default function ExtensionModal({
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Grid item xs={4}>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
size="small"
|
||||
required
|
||||
@@ -155,16 +123,16 @@ export default function ExtensionModal({
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={4}>
|
||||
<Grid item xs={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={extensionObject.active}
|
||||
onChange={(e) =>
|
||||
setExtensionObject({
|
||||
setExtensionObject((extensionObject) => ({
|
||||
...extensionObject,
|
||||
active: e.target.checked,
|
||||
})
|
||||
}))
|
||||
}
|
||||
size="medium"
|
||||
/>
|
||||
@@ -174,337 +142,102 @@ export default function ExtensionModal({
|
||||
}activated`}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={4}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Extension type"
|
||||
value={extensionObject.type}
|
||||
variant="filled"
|
||||
fullWidth
|
||||
disabled
|
||||
helperText="Cannot be changed once created"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<TabContext value={tab}>
|
||||
<TabList
|
||||
aria-label="Extension settings tabs"
|
||||
onChange={(_, val) => setTab(val)}
|
||||
variant="fullWidth"
|
||||
centered
|
||||
style={{
|
||||
marginTop: 0,
|
||||
marginLeft: "calc(var(--dialog-spacing) * -1)",
|
||||
marginRight: "calc(var(--dialog-spacing) * -1)",
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
value="triggersRequirements"
|
||||
label="Triggers & requirements"
|
||||
/>
|
||||
<Tab value="parameters" label="Parameters" />
|
||||
</TabList>
|
||||
<Divider
|
||||
style={{
|
||||
marginTop: -1,
|
||||
marginLeft: "calc(var(--dialog-spacing) * -1)",
|
||||
marginRight: "calc(var(--dialog-spacing) * -1)",
|
||||
}}
|
||||
/>
|
||||
<Stepper
|
||||
nonLinear
|
||||
activeStep={activeStep}
|
||||
orientation="vertical"
|
||||
sx={{
|
||||
mt: 0,
|
||||
|
||||
<StyledTabPanel value="triggersRequirements">
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-start"
|
||||
>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl component="fieldset" required>
|
||||
<FormLabel
|
||||
component="legend"
|
||||
sx={{
|
||||
typography: "subtitle2",
|
||||
color: "text.primary",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Triggers
|
||||
</FormLabel>
|
||||
<Typography gutterBottom>
|
||||
Select a trigger that runs your extension code. Selected
|
||||
actions on any cells will trigger the extension.
|
||||
</Typography>
|
||||
|
||||
<FormGroup>
|
||||
{triggerTypes.map((trigger) => (
|
||||
<FormControlLabel
|
||||
label={trigger}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={extensionObject.triggers.includes(
|
||||
trigger
|
||||
)}
|
||||
name={trigger}
|
||||
onChange={() => {
|
||||
if (
|
||||
extensionObject.triggers.includes(trigger)
|
||||
) {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
triggers: extensionObject.triggers.filter(
|
||||
(t) => t !== trigger
|
||||
),
|
||||
});
|
||||
} else {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
triggers: [
|
||||
...extensionObject.triggers,
|
||||
trigger,
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel
|
||||
component="legend"
|
||||
sx={{
|
||||
typography: "subtitle2",
|
||||
color: "text.primary",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Required fields (optional)
|
||||
</FormLabel>
|
||||
<Typography gutterBottom>
|
||||
Optionally, select the fields that are required for the
|
||||
extension to be triggered for a row.
|
||||
</Typography>
|
||||
|
||||
<FormGroup
|
||||
sx={{
|
||||
maxHeight: 42 * 3.5,
|
||||
overflowY: "auto",
|
||||
flexWrap: "nowrap",
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"& > *": { flexShrink: 0 },
|
||||
}}
|
||||
>
|
||||
{columns.sort().map((field) => (
|
||||
<FormControlLabel
|
||||
label={field}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={extensionObject.requiredFields.includes(
|
||||
field
|
||||
)}
|
||||
name={field}
|
||||
onChange={() => {
|
||||
if (
|
||||
extensionObject.requiredFields.includes(field)
|
||||
) {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
requiredFields:
|
||||
extensionObject.requiredFields.filter(
|
||||
(t) => t !== field
|
||||
),
|
||||
});
|
||||
} else {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
requiredFields: [
|
||||
...extensionObject.requiredFields,
|
||||
field,
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{extensionObject.requiredFields.map((trigger, index) => {
|
||||
const isTableColumn = columns.includes(trigger);
|
||||
if (isTableColumn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
sx={{ ml: -1.25, height: 42 }}
|
||||
>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
component="span"
|
||||
aria-label="Delete Firestore field"
|
||||
onClick={() => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
requiredFields:
|
||||
extensionObject.requiredFields.filter(
|
||||
(t) => t !== trigger
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<TextField
|
||||
id={`extensions-requiredFields-firestoreField-${index}`}
|
||||
label="Firestore field"
|
||||
sx={{
|
||||
flexDirection: "row",
|
||||
alignItems: "baseline",
|
||||
"& .MuiInputLabel-root": { pl: 0, pr: 1 },
|
||||
}}
|
||||
value={trigger}
|
||||
onChange={(event) => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
requiredFields:
|
||||
extensionObject.requiredFields.map(
|
||||
(value, i) =>
|
||||
i === index ? event.target.value : value
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-start"
|
||||
alignItems="center"
|
||||
sx={{ height: 42, ml: -0.875 }}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
color="secondary"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
requiredFields: [
|
||||
...extensionObject.requiredFields,
|
||||
"",
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add Firestore field
|
||||
</Button>
|
||||
</Stack>
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Conditions
|
||||
"& .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)}>
|
||||
Trigger events
|
||||
<ExpandIcon />
|
||||
</StepButton>
|
||||
<StepContent>
|
||||
<Typography gutterBottom>
|
||||
Select which events trigger this extension
|
||||
</Typography>
|
||||
<Step1Triggers {...stepProps} />
|
||||
</StepContent>
|
||||
</Step>
|
||||
|
||||
<CodeEditor
|
||||
script={extensionObject.conditions}
|
||||
height="100%"
|
||||
handleChange={(newValue) => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
conditions: newValue,
|
||||
});
|
||||
}}
|
||||
onValideStatusUpdate={({ isValid }) => {
|
||||
if (!conditionEditorActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
setValidation({
|
||||
...validationRef.current,
|
||||
condition: isValid,
|
||||
});
|
||||
console.log(validationRef.current);
|
||||
}}
|
||||
diagnosticsOptions={{
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
noSuggestionDiagnostics: true,
|
||||
}}
|
||||
onMount={() => {
|
||||
setConditionEditorActive(true);
|
||||
}}
|
||||
onUnmount={() => {
|
||||
setConditionEditorActive(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CodeEditorHelper
|
||||
docLink={WIKI_LINKS.extensions}
|
||||
additionalVariables={additionalVariables}
|
||||
/>
|
||||
</StyledTabPanel>
|
||||
|
||||
<StyledTabPanel value="parameters">
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Extension body
|
||||
<Step>
|
||||
<StepButton onClick={() => setActiveStep(1)}>
|
||||
Required fields (optional)
|
||||
<ExpandIcon />
|
||||
</StepButton>
|
||||
<StepContent>
|
||||
<Typography gutterBottom>
|
||||
Optionally, select fields that must have a value set for the
|
||||
extension to be triggered for that row
|
||||
</Typography>
|
||||
<Step2RequiredFields {...stepProps} />
|
||||
</StepContent>
|
||||
</Step>
|
||||
|
||||
<CodeEditor
|
||||
script={extensionObject.extensionBody}
|
||||
height="100%"
|
||||
handleChange={(newValue) => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
extensionBody: newValue,
|
||||
});
|
||||
}}
|
||||
onValidStatusUpdate={({ isValid }) => {
|
||||
if (!bodyEditorActiveRef.current) {
|
||||
return;
|
||||
<Step>
|
||||
<StepButton onClick={() => setActiveStep(2)}>
|
||||
Trigger conditions (optional)
|
||||
<ExpandIcon />
|
||||
</StepButton>
|
||||
<StepContent>
|
||||
<Typography gutterBottom>
|
||||
Optionally, write a function that determines if the extension
|
||||
should be triggered for a given row. Leave the function to
|
||||
always return <code>true</code> if you do not want to write
|
||||
additional logic.
|
||||
</Typography>
|
||||
<Step3Conditions {...stepProps} />
|
||||
</StepContent>
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
<StepButton onClick={() => setActiveStep(3)}>
|
||||
Extension body
|
||||
<ExpandIcon />
|
||||
</StepButton>
|
||||
<StepContent>
|
||||
<Typography gutterBottom>
|
||||
Write the extension body function. Make sure you have set all
|
||||
the required parameters.{" "}
|
||||
<Link
|
||||
href={
|
||||
WIKI_LINKS[
|
||||
`extensions${_upperFirst(extensionObject.type)}`
|
||||
] || WIKI_LINKS.extensions
|
||||
}
|
||||
setValidation({
|
||||
...validationRef.current,
|
||||
extensionBody: isValid,
|
||||
});
|
||||
console.log(validationRef.current);
|
||||
}}
|
||||
diagnosticsOptions={{
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
noSuggestionDiagnostics: true,
|
||||
}}
|
||||
onMount={() => {
|
||||
setBodyEditorActive(true);
|
||||
}}
|
||||
onUnmount={() => {
|
||||
setBodyEditorActive(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CodeEditorHelper
|
||||
docLink={WIKI_LINKS.extensions}
|
||||
additionalVariables={additionalVariables}
|
||||
/>
|
||||
</StyledTabPanel>
|
||||
</TabContext>
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Docs
|
||||
<InlineOpenInNewIcon />
|
||||
</Link>
|
||||
</Typography>
|
||||
<Step4Body {...stepProps} />
|
||||
</StepContent>
|
||||
</Step>
|
||||
</Stepper>
|
||||
</>
|
||||
}
|
||||
actions={{
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IExtensionModalStepProps } from "./ExtensionModal";
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
} from "@mui/material";
|
||||
|
||||
import { triggerTypes } from "./utils";
|
||||
|
||||
export default function Step1Triggers({
|
||||
extensionObject,
|
||||
setExtensionObject,
|
||||
}: IExtensionModalStepProps) {
|
||||
return (
|
||||
<FormControl component="fieldset" required>
|
||||
<FormLabel component="legend" className="visually-hidden">
|
||||
Triggers
|
||||
</FormLabel>
|
||||
|
||||
<FormGroup>
|
||||
{triggerTypes.map((trigger) => (
|
||||
<FormControlLabel
|
||||
key={trigger}
|
||||
label={trigger}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={extensionObject.triggers.includes(trigger)}
|
||||
name={trigger}
|
||||
onChange={() => {
|
||||
setExtensionObject((extensionObject) => {
|
||||
if (extensionObject.triggers.includes(trigger)) {
|
||||
return {
|
||||
...extensionObject,
|
||||
triggers: extensionObject.triggers.filter(
|
||||
(t) => t !== trigger
|
||||
),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...extensionObject,
|
||||
triggers: [...extensionObject.triggers, trigger],
|
||||
};
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { IExtensionModalStepProps } from "./ExtensionModal";
|
||||
import _sortBy from "lodash/sortBy";
|
||||
|
||||
import MultiSelect from "@rowy/multiselect";
|
||||
import { ListItemIcon } from "@mui/material";
|
||||
|
||||
import { useProjectContext } from "contexts/ProjectContext";
|
||||
import { FieldType } from "constants/fields";
|
||||
import { getFieldProp } from "components/fields";
|
||||
|
||||
export default function Step2RequiredFields({
|
||||
extensionObject,
|
||||
setExtensionObject,
|
||||
}: IExtensionModalStepProps) {
|
||||
const { tableState } = useProjectContext();
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
aria-label="Required fields"
|
||||
multiple
|
||||
value={extensionObject.requiredFields}
|
||||
disabled={!tableState?.columns}
|
||||
options={
|
||||
tableState?.columns
|
||||
? _sortBy(Object.values(tableState!.columns), "index")
|
||||
.filter((c) => c.type !== FieldType.id)
|
||||
.map((c) => ({
|
||||
value: c.key,
|
||||
label: c.name,
|
||||
type: c.type,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
onChange={(requiredFields) =>
|
||||
setExtensionObject((e) => ({ ...e, requiredFields }))
|
||||
}
|
||||
TextFieldProps={{ autoFocus: true }}
|
||||
freeText
|
||||
AddButtonProps={{ children: "Add other field" }}
|
||||
AddDialogProps={{
|
||||
title: "Add other field",
|
||||
textFieldLabel: "Field key",
|
||||
}}
|
||||
itemRenderer={(option: {
|
||||
value: string;
|
||||
label: string;
|
||||
type?: FieldType;
|
||||
}) => (
|
||||
<>
|
||||
<ListItemIcon style={{ minWidth: 40 }}>
|
||||
{option.type && getFieldProp("icon", option.type)}
|
||||
</ListItemIcon>
|
||||
{option.label}
|
||||
<code style={{ marginLeft: "auto" }}>{option.value}</code>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { IExtensionModalStepProps } from "./ExtensionModal";
|
||||
import useStateRef from "react-usestateref";
|
||||
|
||||
import CodeEditor from "components/Table/editors/CodeEditor";
|
||||
import CodeEditorHelper from "components/CodeEditorHelper";
|
||||
|
||||
import { WIKI_LINKS } from "constants/externalLinks";
|
||||
|
||||
const additionalVariables = [
|
||||
{
|
||||
key: "change",
|
||||
description:
|
||||
"you can pass in field name to change.before.get() or change.after.get() to get changes",
|
||||
},
|
||||
{
|
||||
key: "triggerType",
|
||||
description: "triggerType indicates the type of the extension invocation",
|
||||
},
|
||||
{
|
||||
key: "fieldTypes",
|
||||
description:
|
||||
"fieldTypes is a map of all fields and its corresponding field type",
|
||||
},
|
||||
{
|
||||
key: "extensionConfig",
|
||||
description: "the configuration object of this extension",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Step3Conditions({
|
||||
extensionObject,
|
||||
setExtensionObject,
|
||||
setValidation,
|
||||
validationRef,
|
||||
}: IExtensionModalStepProps) {
|
||||
const [, setConditionEditorActive, conditionEditorActiveRef] =
|
||||
useStateRef(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<CodeEditor
|
||||
script={extensionObject.conditions}
|
||||
height={200}
|
||||
handleChange={(newValue) => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
conditions: newValue,
|
||||
});
|
||||
}}
|
||||
onValidStatusUpdate={({ isValid }) => {
|
||||
if (!conditionEditorActiveRef.current) return;
|
||||
setValidation({ ...validationRef.current!, condition: isValid });
|
||||
}}
|
||||
diagnosticsOptions={{
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
noSuggestionDiagnostics: true,
|
||||
}}
|
||||
onMount={() => setConditionEditorActive(true)}
|
||||
onUnmount={() => setConditionEditorActive(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CodeEditorHelper
|
||||
docLink={WIKI_LINKS.extensions}
|
||||
additionalVariables={additionalVariables}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/components/Table/TableHeader/Extensions/Step4Body.tsx
Normal file
77
src/components/Table/TableHeader/Extensions/Step4Body.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { IExtensionModalStepProps } from "./ExtensionModal";
|
||||
import _upperFirst from "lodash/upperFirst";
|
||||
import useStateRef from "react-usestateref";
|
||||
|
||||
import CodeEditor from "components/Table/editors/CodeEditor";
|
||||
import CodeEditorHelper from "components/CodeEditorHelper";
|
||||
|
||||
import { WIKI_LINKS } from "constants/externalLinks";
|
||||
|
||||
const additionalVariables = [
|
||||
{
|
||||
key: "change",
|
||||
description:
|
||||
"you can pass in field name to change.before.get() or change.after.get() to get changes",
|
||||
},
|
||||
{
|
||||
key: "triggerType",
|
||||
description: "triggerType indicates the type of the extension invocation",
|
||||
},
|
||||
{
|
||||
key: "fieldTypes",
|
||||
description:
|
||||
"fieldTypes is a map of all fields and its corresponding field type",
|
||||
},
|
||||
{
|
||||
key: "extensionConfig",
|
||||
description: "the configuration object of this extension",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Step4Body({
|
||||
extensionObject,
|
||||
setExtensionObject,
|
||||
setValidation,
|
||||
validationRef,
|
||||
}: IExtensionModalStepProps) {
|
||||
const [, setBodyEditorActive, bodyEditorActiveRef] = useStateRef(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<CodeEditor
|
||||
script={extensionObject.extensionBody}
|
||||
height={400}
|
||||
handleChange={(newValue) => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
extensionBody: newValue,
|
||||
});
|
||||
}}
|
||||
onValidStatusUpdate={({ isValid }) => {
|
||||
if (!bodyEditorActiveRef.current) return;
|
||||
setValidation({
|
||||
...validationRef.current!,
|
||||
extensionBody: isValid,
|
||||
});
|
||||
}}
|
||||
diagnosticsOptions={{
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
noSuggestionDiagnostics: true,
|
||||
}}
|
||||
onMount={() => setBodyEditorActive(true)}
|
||||
onUnmount={() => setBodyEditorActive(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CodeEditorHelper
|
||||
docLink={
|
||||
WIKI_LINKS[`extensions${_upperFirst(extensionObject.type)}`] ||
|
||||
WIKI_LINKS.extensions
|
||||
}
|
||||
additionalVariables={additionalVariables}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import _isEqual from "lodash/isEqual";
|
||||
import { db } from "../../../../firebase";
|
||||
import { useSnackbar } from "notistack";
|
||||
|
||||
import { Breadcrumbs, Typography, Button } from "@mui/material";
|
||||
import { Breadcrumbs } from "@mui/material";
|
||||
|
||||
import TableHeaderButton from "../TableHeaderButton";
|
||||
import ExtensionIcon from "assets/icons/Extension";
|
||||
@@ -17,20 +15,19 @@ import { useAppContext } from "contexts/AppContext";
|
||||
import { useConfirmation } from "components/ConfirmationDialog";
|
||||
import { useSnackLogContext } from "contexts/SnackLogContext";
|
||||
|
||||
import { emptyExtensionObject, IExtension, IExtensionType } from "./utils";
|
||||
import { name } from "@root/package.json";
|
||||
import { emptyExtensionObject, IExtension, ExtensionType } from "./utils";
|
||||
import { runRoutes } from "constants/runRoutes";
|
||||
import { analytics } from "@src/analytics";
|
||||
|
||||
export default function ExtensionsEditor() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
export default function Extensions() {
|
||||
const { tableState, tableActions, rowyRun } = useProjectContext();
|
||||
const appContext = useAppContext();
|
||||
const { requestConfirmation } = useConfirmation();
|
||||
const currentextensionObjects = (tableState?.config.extensionObjects ??
|
||||
|
||||
const currentExtensionObjects = (tableState?.config.extensionObjects ??
|
||||
[]) as IExtension[];
|
||||
const [localExtensionsObjects, setLocalExtensionsObjects] = useState(
|
||||
currentextensionObjects
|
||||
currentExtensionObjects
|
||||
);
|
||||
const [openExtensionList, setOpenExtensionList] = useState(false);
|
||||
const [openMigrationGuide, setOpenMigrationGuide] = useState(false);
|
||||
@@ -39,8 +36,9 @@ export default function ExtensionsEditor() {
|
||||
extensionObject: IExtension;
|
||||
index?: number;
|
||||
} | null>(null);
|
||||
|
||||
const snackLogContext = useSnackLogContext();
|
||||
const edited = !_isEqual(currentextensionObjects, localExtensionsObjects);
|
||||
const edited = !_isEqual(currentExtensionObjects, localExtensionsObjects);
|
||||
|
||||
const tablePathTokens =
|
||||
tableState?.tablePath?.split("/").filter(function (_, i) {
|
||||
@@ -65,7 +63,7 @@ export default function ExtensionsEditor() {
|
||||
body: "You will lose changes you have made to extensions",
|
||||
confirm: "Discard",
|
||||
handleConfirm: () => {
|
||||
setLocalExtensionsObjects(currentextensionObjects);
|
||||
setLocalExtensionsObjects(currentExtensionObjects);
|
||||
setOpenExtensionList(false);
|
||||
},
|
||||
});
|
||||
@@ -199,13 +197,13 @@ export default function ExtensionsEditor() {
|
||||
children={
|
||||
<>
|
||||
<Breadcrumbs aria-label="breadcrumb">
|
||||
{tablePathTokens.map((pathToken) => {
|
||||
return <Typography>{pathToken}</Typography>;
|
||||
})}
|
||||
{tablePathTokens.map((pathToken) => (
|
||||
<code>{pathToken}</code>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
<ExtensionList
|
||||
extensions={localExtensionsObjects}
|
||||
handleAddExtension={(type: IExtensionType) => {
|
||||
handleAddExtension={(type: ExtensionType) => {
|
||||
setExtensionModal({
|
||||
mode: "add",
|
||||
extensionObject: emptyExtensionObject(
|
||||
|
||||
@@ -1,40 +1,4 @@
|
||||
type IExtensionType =
|
||||
| "task"
|
||||
| "docSync"
|
||||
| "historySnapshot"
|
||||
| "algoliaIndex"
|
||||
| "meiliIndex"
|
||||
| "bigqueryIndex"
|
||||
| "slackMessage"
|
||||
| "sendgridEmail"
|
||||
| "apiCall"
|
||||
| "twilioMessage";
|
||||
|
||||
type IExtensionTrigger = "create" | "update" | "delete";
|
||||
|
||||
interface IExtensionEditor {
|
||||
displayName: string;
|
||||
photoURL: string;
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
interface IExtension {
|
||||
// rowy meta fields
|
||||
name: string;
|
||||
active: boolean;
|
||||
lastEditor: IExtensionEditor;
|
||||
|
||||
// ft build fields
|
||||
triggers: IExtensionTrigger[];
|
||||
type: IExtensionType;
|
||||
requiredFields: string[];
|
||||
extensionBody: string;
|
||||
conditions: string;
|
||||
}
|
||||
|
||||
const triggerTypes: IExtensionTrigger[] = ["create", "update", "delete"];
|
||||
|
||||
const extensionTypes: IExtensionType[] = [
|
||||
export const extensionTypes = [
|
||||
"task",
|
||||
"docSync",
|
||||
"historySnapshot",
|
||||
@@ -45,7 +9,46 @@ const extensionTypes: IExtensionType[] = [
|
||||
"sendgridEmail",
|
||||
"apiCall",
|
||||
"twilioMessage",
|
||||
];
|
||||
] as const;
|
||||
|
||||
export type ExtensionType = typeof extensionTypes[number];
|
||||
|
||||
export const extensionNames: Record<ExtensionType, string> = {
|
||||
task: "Task",
|
||||
docSync: "Doc Sync",
|
||||
historySnapshot: "History Snapshot",
|
||||
algoliaIndex: "Algolia Index",
|
||||
meiliIndex: "MeiliSearch Index",
|
||||
bigqueryIndex: "Big Query Index",
|
||||
slackMessage: "Slack Message",
|
||||
sendgridEmail: "SendGrid Email",
|
||||
apiCall: "API Call",
|
||||
twilioMessage: "Twilio Message",
|
||||
};
|
||||
|
||||
export type ExtensionTrigger = "create" | "update" | "delete";
|
||||
|
||||
export interface IExtensionEditor {
|
||||
displayName: string;
|
||||
photoURL: string;
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
export interface IExtension {
|
||||
// rowy meta fields
|
||||
name: string;
|
||||
active: boolean;
|
||||
lastEditor: IExtensionEditor;
|
||||
|
||||
// ft build fields
|
||||
triggers: ExtensionTrigger[];
|
||||
type: ExtensionType;
|
||||
requiredFields: string[];
|
||||
extensionBody: string;
|
||||
conditions: string;
|
||||
}
|
||||
|
||||
export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"];
|
||||
|
||||
const extensionBodyTemplate = {
|
||||
task: `const extensionBody: TaskBody = async({row, db, change, ref}) => {
|
||||
@@ -158,8 +161,8 @@ const extensionBodyTemplate = {
|
||||
}`,
|
||||
};
|
||||
|
||||
function emptyExtensionObject(
|
||||
type: IExtensionType,
|
||||
export function emptyExtensionObject(
|
||||
type: ExtensionType,
|
||||
user: IExtensionEditor
|
||||
): IExtension {
|
||||
return {
|
||||
@@ -176,7 +179,7 @@ function emptyExtensionObject(
|
||||
lastEditor: user,
|
||||
};
|
||||
}
|
||||
function sparkToExtensionObjects(
|
||||
export function sparkToExtensionObjects(
|
||||
sparkConfig: string,
|
||||
user: IExtensionEditor
|
||||
): IExtension[] {
|
||||
@@ -225,8 +228,8 @@ function sparkToExtensionObjects(
|
||||
lastEditor: user,
|
||||
|
||||
// ft build fields
|
||||
triggers: (spark.triggers ?? []) as IExtensionTrigger[],
|
||||
type: spark.type as IExtensionType,
|
||||
triggers: (spark.triggers ?? []) as ExtensionTrigger[],
|
||||
type: spark.type as ExtensionType,
|
||||
requiredFields: spark.requiredFields ?? [],
|
||||
extensionBody: spark.sparkBody,
|
||||
conditions: spark.shouldRun ?? "",
|
||||
@@ -234,11 +237,3 @@ function sparkToExtensionObjects(
|
||||
});
|
||||
return extensionObjects ?? [];
|
||||
}
|
||||
|
||||
export {
|
||||
extensionTypes,
|
||||
triggerTypes,
|
||||
emptyExtensionObject,
|
||||
sparkToExtensionObjects,
|
||||
};
|
||||
export type { IExtension, IExtensionType, IExtensionEditor };
|
||||
|
||||
@@ -91,6 +91,16 @@ export const components = (theme: Theme): ThemeOptions => {
|
||||
|
||||
"& input, & label": theme.typography.body2,
|
||||
},
|
||||
|
||||
".visually-hidden": {
|
||||
position: "absolute",
|
||||
clip: "rect(1px, 1px, 1px, 1px)",
|
||||
overflow: "hidden",
|
||||
height: 1,
|
||||
width: 1,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user