mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
Merge pull request #949 from rowyio/feat/formula-field
[WIP] ROWY-704: Feat formula field
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -164,6 +164,7 @@ export default function withRenderTableCell(
|
||||
<DisplayCellComponent {...basicCellProps} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (disabled || (editorMode !== "inline" && !focusInsideCell))
|
||||
return displayCell;
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
26
src/components/fields/Formula/DisplayCell.tsx
Normal file
26
src/components/fields/Formula/DisplayCell.tsx
Normal 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} />;
|
||||
}
|
||||
144
src/components/fields/Formula/Settings.tsx
Normal file
144
src/components/fields/Formula/Settings.tsx
Normal 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;
|
||||
};
|
||||
8
src/components/fields/Formula/formula.d.ts
vendored
Normal file
8
src/components/fields/Formula/formula.d.ts
vendored
Normal 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";
|
||||
24
src/components/fields/Formula/index.tsx
Normal file
24
src/components/fields/Formula/index.tsx
Normal 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;
|
||||
73
src/components/fields/Formula/useFormula.tsx
Normal file
73
src/components/fields/Formula/useFormula.tsx
Normal 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 };
|
||||
};
|
||||
121
src/components/fields/Formula/util.tsx
Normal file
121
src/components/fields/Formula/util.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
24
src/components/fields/Formula/worker.ts
Normal file
24
src/components/fields/Formula/worker.ts
Normal 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 {};
|
||||
@@ -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,
|
||||
|
||||
@@ -52,6 +52,8 @@ const WIKI_PATHS = {
|
||||
fieldTypesAction: "/field-types/action",
|
||||
fieldTypesAdd: "/field-types/add",
|
||||
|
||||
fieldTypesFormula: "/field-types/formula",
|
||||
|
||||
rowyRun: "/rowy-run",
|
||||
|
||||
extensions: "/extensions",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user