Merge pull request #949 from rowyio/feat/formula-field

[WIP] ROWY-704: Feat formula field
This commit is contained in:
Shams
2022-12-26 21:38:02 +01:00
committed by GitHub
14 changed files with 458 additions and 25 deletions

View File

@@ -23,6 +23,7 @@ to start.
## Working on existing issues
Before you get started working on an [issue](https://github.com/rowyio/rowy/issues), please make sure to share that you are working on it by commenting on the issue and posting a message on #contributions channel in Rowy's [Discord](https://discord.com/invite/fjBugmvzZP). The maintainers will then assign the issue to you after making sure any relevant information or context in addition is provided before you can start on the task.
Once you are assigned a task, please provide periodic updates or share any questions or roadblocks on either discord or the Github issue, so that the commmunity or the project maintainers can provide you any feedback or guidance as needed. If you are inactive for more than 1-2 week on a issue that was assigned to you, then we will assume you have stopped working on it and we will unassign it from you - so that we can give a chance to others in the community to work on it.

View File

@@ -50,8 +50,9 @@ export default function ColumnConfigModal({
const rendedFieldSettings = useMemo(
() =>
[FieldType.derivative, FieldType.aggregate].includes(column.type) &&
newConfig.renderFieldType
[FieldType.derivative, FieldType.aggregate, FieldType.formula].includes(
column.type
) && newConfig.renderFieldType
? getFieldProp("settings", newConfig.renderFieldType)
: null,
[newConfig.renderFieldType, column.type]

View File

@@ -164,6 +164,7 @@ export default function withRenderTableCell(
<DisplayCellComponent {...basicCellProps} />
</div>
);
if (disabled || (editorMode !== "inline" && !focusInsideCell))
return displayCell;

View File

@@ -2,27 +2,31 @@ import { ISettingsProps } from "@src/components/fields/types";
import { TextField, Button } from "@mui/material";
export default function Settings({ onChange, config }: ISettingsProps) {
const copyStandardRegex = () => {
onChange("validationRegex")("^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+.[a-zA-z]{2,3}$");
}
return (
<>
<TextField
type="text"
label="Validation regex"
id="validation-regex"
value={config.validationRegex}
fullWidth
onChange={(e) => {
if (e.target.value === "") onChange("validationRegex")(null);
else onChange("validationRegex")(e.target.value);
}}
/>
<Button style={{ width: "200px", margin: "20px auto auto" }} onClick={copyStandardRegex}>
Use standard regex
</Button>
</>
const copyStandardRegex = () => {
onChange("validationRegex")(
"^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+.[a-zA-z]{2,3}$"
);
};
return (
<>
<TextField
type="text"
label="Validation regex"
id="validation-regex"
value={config.validationRegex}
fullWidth
onChange={(e) => {
if (e.target.value === "") onChange("validationRegex")(null);
else onChange("validationRegex")(e.target.value);
}}
/>
<Button
style={{ width: "200px", margin: "20px auto auto" }}
onClick={copyStandardRegex}
>
Use standard regex
</Button>
</>
);
}

View File

@@ -0,0 +1,26 @@
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import { IDisplayCellProps } from "@src/components/fields/types";
import { useFormula } from "./useFormula";
import { defaultFn, getDisplayCell } from "./util";
export default function Formula(props: IDisplayCellProps) {
const { result, error, loading } = useFormula({
row: props.row,
listenerFields: props.column.config?.listenerFields || [],
formulaFn: props.column.config?.formulaFn || defaultFn,
});
const type = props.column.config?.renderFieldType;
const DisplayCell = getDisplayCell(type);
if (error) {
return <>Error: {error.message}</>;
}
if (loading) {
return <CircularProgressOptical id="progress" size={20} sx={{ m: 0.25 }} />;
}
return <DisplayCell {...props} value={result} disabled={true} />;
}

View File

@@ -0,0 +1,144 @@
import { lazy, Suspense } from "react";
import { useDebouncedCallback } from "use-debounce";
import { useAtom } from "jotai";
import MultiSelect from "@rowy/multiselect";
import {
Grid,
InputLabel,
Typography,
Stack,
FormHelperText,
Tooltip,
} from "@mui/material";
import FieldSkeleton from "@src/components/SideDrawer/FieldSkeleton";
import { ISettingsProps } from "@src/components/fields/types";
import { tableColumnsOrderedAtom, tableScope } from "@src/atoms/tableScope";
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
import { defaultFn, listenerFieldTypes, outputFieldTypes } from "./util";
import { getFieldProp } from "..";
/* eslint-disable import/no-webpack-loader-syntax */
import formulaDefs from "!!raw-loader!./formula.d.ts";
const CodeEditor = lazy(
() =>
import("@src/components/CodeEditor" /* webpackChunkName: "CodeEditor" */)
);
const diagnosticsOptions = {
noSemanticValidation: false,
noSyntaxValidation: false,
noSuggestionDiagnostics: true,
};
export default function Settings({
config,
onChange,
onBlur,
errors,
}: ISettingsProps) {
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const returnType = getFieldProp("dataType", config.renderFieldType) ?? "any";
const formulaFn = config?.formulaFn ? config.formulaFn : defaultFn;
return (
<Stack spacing={1}>
<Grid container direction="row" spacing={2} flexWrap="nowrap">
<Grid item xs={12} md={6}>
<MultiSelect
label="Listener fields"
options={tableColumnsOrdered
.filter((c) => listenerFieldTypes.includes(c.type))
.map((c) => ({ label: c.name, value: c.key }))}
value={config.listenerFields ?? []}
onChange={onChange("listenerFields")}
TextFieldProps={{
helperText: (
<>
{errors.listenerFields && (
<FormHelperText error style={{ margin: 0 }}>
{errors.listenerFields}
</FormHelperText>
)}
<FormHelperText error={false} style={{ margin: 0 }}>
Changes to these fields will trigger the evaluation of the
column.
</FormHelperText>
</>
),
FormHelperTextProps: { component: "div" } as any,
required: true,
error: errors.listenerFields,
onBlur,
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<FieldsDropdown
label="Output field type"
value={config.renderFieldType}
options={outputFieldTypes}
onChange={(value) => {
onChange("renderFieldType")(value);
}}
TextFieldProps={{
required: true,
error: errors.renderFieldType,
helperText: errors.renderFieldType,
onBlur,
}}
/>
</Grid>
</Grid>
<InputLabel>Formula script</InputLabel>
<div>
<Stack
direction="row"
alignItems="flex-start"
justifyItems="space-between"
justifyContent="space-between"
marginBottom={1}
>
<Typography variant="body2" color="textSecondary">
Available:
</Typography>
<Grid
container
spacing={1}
style={{ flexGrow: 1, marginTop: -8, marginLeft: 0 }}
>
<Grid item>
<Tooltip title="Current row's data">
<code>row</code>
</Tooltip>
</Grid>
</Grid>
</Stack>
<Suspense fallback={<FieldSkeleton height={200} />}>
<CodeEditor
diagnosticsOptions={diagnosticsOptions}
value={formulaFn}
extraLibs={[
formulaDefs.replace(
`"PLACEHOLDER_OUTPUT_TYPE"`,
`${returnType} | Promise<${returnType}>`
),
]}
onChange={useDebouncedCallback(onChange("formulaFn"), 300)}
/>
</Suspense>
</div>
</Stack>
);
}
export const settingsValidator = (config: any) => {
const errors: Record<string, any> = {};
if (config.error) errors.error = config.error;
return errors;
};

View File

@@ -0,0 +1,8 @@
type FormulaContext = {
row: Row;
// ref: FirebaseFirestore.DocumentReference;
// storage: firebasestorage.Storage;
// db: FirebaseFirestore.Firestore;
};
type Formula = (context: FormulaContext) => "PLACEHOLDER_OUTPUT_TYPE";

View File

@@ -0,0 +1,24 @@
import FormulaIcon from "@mui/icons-material/Functions";
import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import DisplayCell from "./DisplayCell";
import Settings, { settingsValidator } from "./Settings";
export const config: IFieldConfig = {
type: FieldType.formula,
name: "Formula",
group: "Client Function",
dataType: "any",
initialValue: "",
icon: <FormulaIcon />,
description: "Client Function (Alpha)",
TableCell: withRenderTableCell(DisplayCell as any, null, undefined, {
usesRowData: true,
}),
SideDrawerField: () => null as any,
settings: Settings,
settingsValidator: settingsValidator,
requireConfiguration: true,
};
export default config;

View File

@@ -0,0 +1,73 @@
import { useEffect, useMemo, useState } from "react";
import { pick, zipObject } from "lodash-es";
import { useAtom } from "jotai";
import { TableRow } from "@src/types/table";
import { tableColumnsOrderedAtom, tableScope } from "@src/atoms/tableScope";
import { listenerFieldTypes, useDeepCompareMemoize } from "./util";
export const useFormula = ({
row,
listenerFields,
formulaFn,
}: {
row: TableRow;
listenerFields: string[];
formulaFn: string;
}) => {
const [result, setResult] = useState(null);
const [error, setError] = useState<any>(null);
const [loading, setLoading] = useState<boolean>(false);
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const availableColumns = tableColumnsOrdered
.filter((c) => listenerFieldTypes.includes(c.type))
.map((c) => c.key);
const availableFields = useMemo(
() => ({
...zipObject(
availableColumns,
Array(availableColumns.length).fill(undefined)
),
...pick(row, availableColumns),
}),
[row, availableColumns]
);
const listeners = useMemo(
() => pick(availableFields, listenerFields),
[availableFields, listenerFields]
);
useEffect(() => {
setError(null);
setLoading(true);
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
worker.onmessage = ({ data: { result, error } }: any) => {
worker.terminate();
if (error) {
setError(error);
} else {
setResult(result);
}
setLoading(false);
};
worker.postMessage({
formulaFn,
row: availableFields,
});
return () => {
worker.terminate();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [useDeepCompareMemoize(listeners), formulaFn]);
return { result, error, loading };
};

View File

@@ -0,0 +1,121 @@
import { useMemo, useRef } from "react";
import { isEqual } from "lodash-es";
import { FieldType } from "@src/constants/fields";
import ShortTextDisplayCell from "@src/components/fields/ShortText/DisplayCell";
import LongTextDisplayCell from "@src/components/fields/LongText/DisplayCell";
import RichTextDisplayCell from "@src/components/fields/RichText/DisplayCell";
import UrlDisplayCell from "@src/components/fields/Url/DisplayCell";
import NumberDisplayCell from "@src/components/fields/Number/DisplayCell";
import CheckboxDisplayCell from "@src/components/fields/Checkbox/DisplayCell";
import PercentageDisplayCell from "@src/components/fields/Percentage/DisplayCell";
import RatingDisplayCell from "@src/components/fields/Rating/DisplayCell";
import SliderDisplayCell from "@src/components/fields/Slider/DisplayCell";
import SingleSelectDisplayCell from "@src/components/fields/SingleSelect/DisplayCell";
import MultiSelectDisplayCell from "@src/components/fields/MultiSelect/DisplayCell";
import ColorDisplayCell from "@src/components/fields/Color/DisplayCell";
import GeoPointDisplayCell from "@src/components/fields/GeoPoint/DisplayCell";
import DateDisplayCell from "@src/components/fields/Date/DisplayCell";
import DateTimeDisplayCell from "@src/components/fields/DateTime/DisplayCell";
import ImageDisplayCell from "@src/components/fields/Image/DisplayCell";
import FileDisplayCell from "@src/components/fields/File/DisplayCell";
import JsonDisplayCell from "@src/components/fields/Json/DisplayCell";
import CodeDisplayCell from "@src/components/fields/Code/DisplayCell";
import MarkdownDisplayCell from "@src/components/fields/Markdown/DisplayCell";
import CreatedByDisplayCell from "@src/components/fields/CreatedBy/DisplayCell";
export function useDeepCompareMemoize<T>(value: T) {
const ref = useRef<T>(value);
const signalRef = useRef<number>(0);
if (!isEqual(value, ref.current)) {
ref.current = value;
signalRef.current += 1;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => ref.current, [signalRef.current]);
}
export const listenerFieldTypes = Object.values(FieldType).filter(
(type) =>
![FieldType.formula, FieldType.subTable, FieldType.last].includes(type)
);
export const outputFieldTypes = Object.values(FieldType).filter(
(type) =>
![
FieldType.formula,
FieldType.derivative,
FieldType.action,
FieldType.status,
FieldType.aggregate,
FieldType.connectService,
FieldType.connectTable,
FieldType.connector,
FieldType.duration,
FieldType.subTable,
FieldType.reference,
FieldType.createdAt,
FieldType.createdBy,
FieldType.updatedAt,
FieldType.updatedBy,
FieldType.last,
].includes(type)
);
export const defaultFn = `const formula:Formula = async ({ row })=> {
// Write your formula code here
// for example:
// return row.a + row.b;
// checkout the documentation for more info: https://docs.rowy.io/field-types/formula
}
`;
export const getDisplayCell = (type: FieldType) => {
switch (type) {
case FieldType.longText:
return LongTextDisplayCell;
case FieldType.richText:
return RichTextDisplayCell;
case FieldType.url:
return UrlDisplayCell;
case FieldType.number:
return NumberDisplayCell;
case FieldType.checkbox:
return CheckboxDisplayCell;
case FieldType.percentage:
return PercentageDisplayCell;
case FieldType.rating:
return RatingDisplayCell;
case FieldType.slider:
return SliderDisplayCell;
case FieldType.singleSelect:
return SingleSelectDisplayCell;
case FieldType.multiSelect:
return MultiSelectDisplayCell;
case FieldType.color:
return ColorDisplayCell;
case FieldType.geoPoint:
return GeoPointDisplayCell;
case FieldType.date:
return DateDisplayCell;
case FieldType.dateTime:
return DateTimeDisplayCell;
case FieldType.image:
return ImageDisplayCell;
case FieldType.file:
return FileDisplayCell;
case FieldType.json:
return JsonDisplayCell;
case FieldType.code:
return CodeDisplayCell;
case FieldType.markdown:
return MarkdownDisplayCell;
case FieldType.createdBy:
return CreatedByDisplayCell;
default:
return ShortTextDisplayCell;
}
};

View File

@@ -0,0 +1,24 @@
onmessage = async ({ data }) => {
try {
const { formulaFn, row } = data;
const AsyncFunction = async function () {}.constructor as any;
const [_, fnBody] = formulaFn.match(/=>\s*({?[\s\S]*}?)$/);
if (!fnBody) return;
const fn = new AsyncFunction(
"row",
`const fn = async () => \n${fnBody}\n return fn();`
);
const result = await fn(row);
postMessage({ result });
} catch (error: any) {
console.error("Error: ", error);
postMessage({
error,
});
} finally {
// eslint-disable-next-line no-restricted-globals
self.close();
}
};
export {};

View File

@@ -33,6 +33,7 @@ import Json from "./Json";
import Code from "./Code";
import Action from "./Action";
import Derivative from "./Derivative";
import Formula from "./Formula";
import Markdown from "./Markdown";
// // import Aggregate from "./Aggregate";
import Status from "./Status";
@@ -86,6 +87,8 @@ export const FIELDS: IFieldConfig[] = [
Derivative,
// // Aggregate,
Status,
/** CLIENT FUNCTION */
Formula,
/** AUDITING */
CreatedBy,
UpdatedBy,

View File

@@ -52,6 +52,8 @@ const WIKI_PATHS = {
fieldTypesAction: "/field-types/action",
fieldTypesAdd: "/field-types/add",
fieldTypesFormula: "/field-types/formula",
rowyRun: "/rowy-run",
extensions: "/extensions",

View File

@@ -40,13 +40,14 @@ export enum FieldType {
derivative = "DERIVATIVE",
aggregate = "AGGREGATE",
status = "STATUS",
// CLIENT FUNCTION
formula = "FORMULA",
// AUDIT
createdBy = "CREATED_BY",
updatedBy = "UPDATED_BY",
createdAt = "CREATED_AT",
updatedAt = "UPDATED_AT",
// METADATA
user = "USER",
id = "ID",
last = "LAST",