Merge remote-tracking branch 'upstream/develop' into rowy-135

This commit is contained in:
Emmanuel Watila
2023-03-06 12:57:13 +01:00
122 changed files with 4838 additions and 1280 deletions

View File

@@ -4,7 +4,7 @@ import { get } from "lodash-es";
import { useAtom, useSetAtom } from "jotai";
import { httpsCallable } from "firebase/functions";
import { Fab, FabProps } from "@mui/material";
import { Button, Fab, FabProps, Link } from "@mui/material";
import RunIcon from "@mui/icons-material/PlayArrow";
import RedoIcon from "@mui/icons-material/Refresh";
import UndoIcon from "@mui/icons-material/Undo";
@@ -118,11 +118,32 @@ export default function ActionFab({
} else {
result = await handleCallableAction(data);
}
const { message, success } = result ?? {};
const { message, success, link } = result ?? {};
enqueueSnackbar(
typeof message === "string" ? message : JSON.stringify(message),
{
variant: success ? "success" : "error",
action: link ? (
typeof link === "string" ? (
<Button
variant="outlined"
href={link}
component={Link}
target="_blank"
>
Link
</Button>
) : (
<Button
href={link.url}
component={Link}
variant="outlined"
target="_blank"
>
{link.label}
</Button>
)
) : undefined,
}
);
} catch (e) {

View File

@@ -130,7 +130,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
: config?.runFn
? config.runFn
: config?.script
? `const action:Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
? `const action:Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")}
}`
: RUN_ACTION_TEMPLATE;
@@ -140,7 +140,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
: config.undoFn
? config.undoFn
: get(config, "undo.script")
? `const action : Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
? `const action : Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
${get(config, "undo.script")}
}`
: UNDO_ACTION_TEMPLATE;

View File

@@ -15,12 +15,14 @@ type ActionContext = {
auth: firebaseauth.BaseAuth;
actionParams: actionParams;
user: ActionUser;
logging: RowyLogging;
};
type ActionResult = {
success: boolean;
message?: any;
status?: string | number | null | undefined;
link?: string | { url: string; label: string };
};
type Action = (context: ActionContext) => Promise<ActionResult> | ActionResult;

View File

@@ -30,6 +30,7 @@ export const config: IFieldConfig = {
SideDrawerField,
settings: Settings,
requireConfiguration: true,
requireCloudFunction: true,
sortKey: "status",
};
export default config;

View File

@@ -1,54 +1,67 @@
export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
// Write your action code here
// for example:
// const authToken = await rowy.secrets.get("service")
// try {
// const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// 'Authorization': authToken
// },
// body: JSON.stringify(row)
// })
//
// return {
// success: true,
// message: 'User updated successfully on example service',
// status: "upto date"
// }
// } catch (error) {
// return {
// success: false,
// message: 'User update failed on example service',
// }
// }
// checkout the documentation for more info: https://docs.rowy.io/field-types/action#script
}`;
export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("action started")
// Import any NPM package needed
// const lodash = require('lodash');
// Example:
/*
const authToken = await rowy.secrets.get("service")
try {
const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': authToken
},
body: JSON.stringify(row)
})
return {
success: true,
message: 'User updated successfully on example service',
status: "upto date"
}
} catch (error) {
return {
success: false,
message: 'User update failed on example service',
}
}
*/
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`;
export const UNDO_ACTION_TEMPLATE = `const action : Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
// Write your undo code here
// for example:
// const authToken = await rowy.secrets.get("service")
// try {
// const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{
// method: 'DELETE',
// headers: {
// 'Content-Type': 'application/json',
// 'Authorization': authToken
// },
// body: JSON.stringify(row)
// })
//
// return {
// success: true,
// message: 'User deleted successfully on example service',
// status: null
// }
// } catch (error) {
// return {
// success: false,
// message: 'User delete failed on example service',
// }
// }
}`;
export const UNDO_ACTION_TEMPLATE = `const action : Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("action started")
// Import any NPM package needed
// const lodash = require('lodash');
// Example:
/*
const authToken = await rowy.secrets.get("service")
try {
const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': authToken
},
body: JSON.stringify(row)
})
return {
success: true,
message: 'User deleted successfully on example service',
status: null
}
} catch (error) {
return {
success: false,
message: 'User delete failed on example service',
}
}
*/
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`;

View File

@@ -0,0 +1,24 @@
import { useTheme } from "@mui/material";
import { IDisplayCellProps } from "@src/components/fields/types";
export default function Array({ value }: IDisplayCellProps) {
const theme = useTheme();
if (!value) {
return null;
}
return (
<div
style={{
width: "100%",
maxHeight: "100%",
whiteSpace: "pre-wrap",
lineHeight: theme.typography.body2.lineHeight,
fontFamily: theme.typography.fontFamilyMono,
}}
>
{JSON.stringify(value, null, 4)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useRef, useState } from "react";
import {
Button,
ButtonGroup,
ListItemText,
MenuItem,
Select,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import { ChevronDown as ArrowDropDownIcon } from "@src/assets/icons";
import { FieldType } from "@src/components/fields/types";
import { getFieldProp } from "@src/components/fields";
import {
ArraySupportedFields,
ArraySupportedFiledTypes,
} from "./SupportedTypes";
function AddButton({ handleAddNew }: { handleAddNew: Function }) {
const anchorEl = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [fieldType, setFieldType] = useState<ArraySupportedFiledTypes>(
FieldType.shortText
);
return (
<>
<ButtonGroup
variant="contained"
color="primary"
aria-label="Split button"
sx={{ width: "fit-content" }}
ref={anchorEl}
>
<Button
variant="contained"
color="primary"
onClick={() => handleAddNew(fieldType)}
startIcon={<AddIcon />}
>
Add {getFieldProp("name", fieldType)}
</Button>
<Button
variant="contained"
color="primary"
aria-label="Select add element"
aria-haspopup="menu"
style={{ padding: 0 }}
onClick={() => setOpen(true)}
id="add-row-menu-button"
aria-controls={open ? "add-new-element" : undefined}
aria-expanded={open ? "true" : "false"}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Select
id="add-new-element"
open={open}
onClose={() => setOpen(false)}
label="Add new element"
style={{ display: "none" }}
value={fieldType}
onChange={(e) => setFieldType(e.target.value as typeof fieldType)}
MenuProps={{
anchorEl: anchorEl.current,
MenuListProps: { "aria-labelledby": "add-row-menu-button" },
anchorOrigin: { horizontal: "left", vertical: "bottom" },
transformOrigin: { horizontal: "left", vertical: "top" },
}}
>
{ArraySupportedFields.map((fieldType, i) => (
<MenuItem value={fieldType} disabled={false} key={i + ""}>
<ListItemText
primary={getFieldProp("name", fieldType)}
secondary={getFieldProp("description", fieldType)}
secondaryTypographyProps={{
variant: "caption",
whiteSpace: "pre-line",
}}
/>
</MenuItem>
))}
</Select>
</>
);
}
export default AddButton;

View File

@@ -0,0 +1,108 @@
import { DocumentReference, GeoPoint, Timestamp } from "firebase/firestore";
import { FieldType } from "@src/components/fields/types";
import NumberValueSidebar from "@src/components/fields/Number/SideDrawerField";
import ShortTextValueSidebar from "@src/components/fields/ShortText/SideDrawerField";
import JsonValueSidebar from "@src/components/fields/Json/SideDrawerField";
import CheckBoxValueSidebar from "@src/components/fields/Checkbox/SideDrawerField";
import GeoPointValueSidebar from "@src/components/fields/GeoPoint/SideDrawerField";
import DateTimeValueSidebar from "@src/components/fields/DateTime/SideDrawerField";
import ReferenceValueSidebar from "@src/components/fields/Reference/SideDrawerField";
export const ArraySupportedFields = [
FieldType.number,
FieldType.shortText,
FieldType.json,
FieldType.checkbox,
FieldType.geoPoint,
FieldType.dateTime,
FieldType.reference,
] as const;
export type ArraySupportedFiledTypes = typeof ArraySupportedFields[number];
export const SupportedTypes = {
[FieldType.number]: {
Sidebar: NumberValueSidebar,
initialValue: 0,
dataType: "common",
instance: Object,
},
[FieldType.shortText]: {
Sidebar: ShortTextValueSidebar,
initialValue: "",
dataType: "common",
instance: Object,
},
[FieldType.checkbox]: {
Sidebar: CheckBoxValueSidebar,
initialValue: false,
dataType: "common",
instance: Object,
},
[FieldType.json]: {
Sidebar: JsonValueSidebar,
initialValue: {},
sx: [
{
marginTop: "24px",
},
],
dataType: "common",
instance: Object,
},
[FieldType.geoPoint]: {
Sidebar: GeoPointValueSidebar,
initialValue: new GeoPoint(0, 0),
dataType: "firestore-type",
instance: GeoPoint,
},
[FieldType.dateTime]: {
Sidebar: DateTimeValueSidebar,
initialValue: Timestamp.now(),
dataType: "firestore-type",
instance: Timestamp,
},
[FieldType.reference]: {
Sidebar: ReferenceValueSidebar,
initialValue: null,
dataType: "firestore-type",
instance: DocumentReference,
},
};
export function detectType(value: any): ArraySupportedFiledTypes {
if (value === null) {
return FieldType.reference;
}
for (const supportedField of ArraySupportedFields) {
if (SupportedTypes[supportedField].dataType === "firestore-type") {
if (value instanceof SupportedTypes[supportedField].instance) {
return supportedField;
}
}
}
switch (typeof value) {
case "bigint":
case "number": {
return FieldType.number;
}
case "string": {
return FieldType.shortText;
}
case "boolean": {
return FieldType.checkbox;
}
case "object": {
if (+new Date(value)) {
return FieldType.dateTime;
}
return FieldType.json;
}
default: {
return FieldType.shortText;
}
}
}

View File

@@ -0,0 +1,205 @@
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from "react-beautiful-dnd";
import { Stack, Box, Button, ListItem, List } from "@mui/material";
import ClearIcon from "@mui/icons-material/Clear";
import DragIndicatorOutlinedIcon from "@mui/icons-material/DragIndicatorOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutline";
import { FieldType, ISideDrawerFieldProps } from "@src/components/fields/types";
import { TableRowRef } from "@src/types/table";
import AddButton from "./AddButton";
import { getPseudoColumn } from "./utils";
import {
ArraySupportedFiledTypes,
detectType,
SupportedTypes,
} from "./SupportedTypes";
function ArrayFieldInput({
onChange,
value,
_rowy_ref,
index,
onRemove,
onSubmit,
id,
}: {
index: number;
onRemove: (index: number) => void;
onChange: (value: any) => void;
value: any;
onSubmit: () => void;
_rowy_ref: TableRowRef;
id: string;
}) {
const typeDetected = detectType(value);
const Sidebar = SupportedTypes[typeDetected].Sidebar;
return (
<Draggable draggableId={id} index={index} isDragDisabled={false}>
{(provided) => (
<ListItem
sx={[{ padding: 0, marginBottom: "12px" }]}
ref={provided.innerRef}
{...provided.draggableProps}
>
<Box
sx={[{ position: "relative", height: "1.5rem" }]}
{...provided.dragHandleProps}
>
<DragIndicatorOutlinedIcon
color="disabled"
sx={[
{
marginRight: "6px",
opacity: (theme) =>
false ? theme.palette.action.disabledOpacity : 1,
},
]}
/>
</Box>
<Stack
width={"100%"}
sx={
typeDetected === FieldType.json
? SupportedTypes[typeDetected].sx
: null
}
>
<Sidebar
disabled={false}
onDirty={onChange}
onChange={onChange}
onSubmit={onSubmit}
column={getPseudoColumn(typeDetected, index, value)}
value={value}
_rowy_ref={_rowy_ref}
/>
</Stack>
<Box
sx={[{ position: "relative", height: "1.5rem" }]}
onClick={() => onRemove(index)}
>
<DeleteIcon
color="disabled"
sx={[
{
marginLeft: "6px",
":hover": {
cursor: "pointer",
color: "error.main",
},
},
]}
/>
</Box>
</ListItem>
)}
</Draggable>
);
}
export default function ArraySideDrawerField({
column,
value,
onChange,
onSubmit,
disabled,
_rowy_ref,
onDirty,
...props
}: ISideDrawerFieldProps) {
const handleAddNew = (fieldType: ArraySupportedFiledTypes) => {
onChange([...(value || []), SupportedTypes[fieldType].initialValue]);
onDirty(true);
};
const handleChange = (newValue_: any, indexUpdated: number) => {
onChange(
[...(value || [])].map((v: any, i) => {
if (i === indexUpdated) {
return newValue_;
}
return v;
})
);
};
const handleRemove = (index: number) => {
value.splice(index, 1);
onChange([...value]);
onDirty(true);
onSubmit();
};
const handleClearField = () => {
onChange([]);
onSubmit();
};
function handleOnDragEnd(result: DropResult) {
if (
!result.destination ||
result.destination.index === result.source.index
) {
return;
}
const list = Array.from(value);
const [removed] = list.splice(result.source.index, 1);
list.splice(result.destination.index, 0, removed);
onChange(list);
onSubmit();
}
if (value === undefined || Array.isArray(value)) {
return (
<>
<DragDropContext onDragEnd={handleOnDragEnd}>
<Droppable droppableId="columns_manager" direction="vertical">
{(provided) => (
<List {...provided.droppableProps} ref={provided.innerRef}>
{(value || []).map((v: any, index: number) => (
<ArrayFieldInput
key={`index-${index}-value`}
id={`index-${index}-value`}
_rowy_ref={_rowy_ref}
value={v}
onChange={(newValue) => handleChange(newValue, index)}
onRemove={handleRemove}
index={index}
onSubmit={onSubmit}
/>
))}
{provided.placeholder}
</List>
)}
</Droppable>
</DragDropContext>
<AddButton handleAddNew={handleAddNew} />
</>
);
}
return (
<Stack>
<Box component="pre" my="0">
{JSON.stringify(value, null, 4)}
</Box>
<Button
sx={{ mt: 1, width: "fit-content" }}
onClick={handleClearField}
variant="text"
color="warning"
startIcon={<ClearIcon />}
>
Clear field
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,59 @@
import { ColumnConfig } from "@src/types/table";
import { FieldType } from "@src/constants/fields";
import { ArraySupportedFiledTypes } from "./SupportedTypes";
import { GeoPoint, DocumentReference } from "firebase/firestore";
export function getPseudoColumn(
fieldType: FieldType,
index: number,
value: any
): ColumnConfig {
return {
fieldName: (+new Date()).toString(),
index: index,
key: (+new Date()).toString(),
name: value + "",
type: fieldType,
};
}
// archive: detectType / TODO: remove
export function detectType(value: any): ArraySupportedFiledTypes {
if (value === null) {
return FieldType.reference;
}
console.log(typeof GeoPoint);
console.log(value instanceof DocumentReference, value);
if (typeof value === "object") {
const keys = Object.keys(value);
// console.log({ keys, value }, typeof value);
if (keys.length === 2) {
if (keys.includes("_lat") && keys.includes("_long")) {
return FieldType.geoPoint;
}
if (keys.includes("nanoseconds") && keys.includes("seconds")) {
return FieldType.dateTime;
}
}
if (+new Date(value)) {
return FieldType.dateTime;
}
return FieldType.json;
}
switch (typeof value) {
case "bigint":
case "number": {
return FieldType.number;
}
case "string": {
return FieldType.shortText;
}
case "boolean": {
return FieldType.checkbox;
}
default: {
return FieldType.shortText;
}
}
}

View File

@@ -0,0 +1,30 @@
import { lazy } from "react";
import DataArrayIcon from "@mui/icons-material/DataArray";
import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import DisplayCell from "./DisplayCell";
const SideDrawerField = lazy(
() =>
import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Array" */)
);
export const config: IFieldConfig = {
type: FieldType.array,
name: "Array",
group: "Code",
dataType: "object",
initialValue: [],
initializable: true,
icon: <DataArrayIcon />,
description:
"Connects to a sub-table in the current row. Also displays number of rows inside the sub-table. Max sub-table depth: 100.",
TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", {
popoverProps: { PaperProps: { sx: { p: 1, minWidth: "200px" } } },
}),
SideDrawerField,
requireConfiguration: false,
};
export default config;

View File

@@ -127,7 +127,9 @@ export default function PopupContents({
<Grid item xs>
<List sx={{ overflowY: "auto" }}>
{hits.map((hit) => {
const isSelected = selectedValues.some((v) => v === hit[elementId]);
const isSelected = selectedValues?.some(
(v) => v === hit[elementId]
);
return (
<MenuItem
key={get(hit, elementId)}

View File

@@ -15,6 +15,7 @@ type ConnectorContext = {
auth: firebaseauth.BaseAuth;
query: string;
user: ConnectorUser;
logging: RowyLogging;
};
type ConnectorResult = any[];
type Connector = (

View File

@@ -34,6 +34,7 @@ export const config: IFieldConfig = {
}),
SideDrawerField,
requireConfiguration: true,
requireCloudFunction: true,
settings: Settings,
};
export default config;

View File

@@ -11,9 +11,15 @@ export const replacer = (data: any) => (m: string, key: string) => {
return get(data, objKey, defaultValue);
};
export const baseFunction = `const connectorFn: Connector = async ({query, row, user}) => {
// TODO: Implement your service function here
export const baseFunction = `const connectorFn: Connector = async ({query, row, user, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("connectorFn started")
// Import any NPM package needed
// const lodash = require('lodash');
return [];
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
};`;
export const getLabel = (config: any, row: TableRow) => {

View File

@@ -34,10 +34,9 @@ export const config: IFieldConfig = {
SideDrawerField,
filter: { operators: filterOperators, valueFormatter },
settings: Settings,
csvImportParser: (value, config) =>
parse(value, config?.format ?? DATE_FORMAT, new Date()),
csvImportParser: (value, config) => parse(value, DATE_FORMAT, new Date()),
csvExportFormatter: (value: any, config?: any) =>
format(value.toDate(), config?.format ?? DATE_FORMAT),
format(value.toDate(), DATE_FORMAT),
};
export default config;

View File

@@ -1,7 +1,7 @@
import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import { parseJSON, format } from "date-fns";
import { format } from "date-fns";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import DateTimeIcon from "@mui/icons-material/AccessTime";
@@ -46,9 +46,9 @@ export const config: IFieldConfig = {
customInput: FilterCustomInput,
},
settings: Settings,
csvImportParser: (value) => parseJSON(value).getTime(),
csvImportParser: (value) => new Date(value),
csvExportFormatter: (value: any, config?: any) =>
format(value.toDate(), config?.format ?? DATE_TIME_FORMAT),
format(value.toDate(), DATE_TIME_FORMAT),
};
export default config;

View File

@@ -2,13 +2,17 @@ import { useAtom } from "jotai";
import { find, get } from "lodash-es";
import { useSnackbar } from "notistack";
import { Button } from "@mui/material";
import ReEvalIcon from "@mui/icons-material/ReplayOutlined";
import EvalIcon from "@mui/icons-material/PlayCircleOutline";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import {
projectScope,
compatibleRowyRunVersionAtom,
rowyRunAtom,
projectIdAtom,
projectSettingsAtom,
} from "@src/atoms/projectScope";
import {
tableScope,
@@ -34,6 +38,8 @@ export const ContextMenuActions: IFieldConfig["contextMenuActions"] = (
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableRows] = useAtom(tableRowsAtom, tableScope);
const [projectId] = useAtom(projectIdAtom, projectScope);
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const [compatibleRowyRunVersion] = useAtom(
compatibleRowyRunVersionAtom,
@@ -76,8 +82,32 @@ export const ContextMenuActions: IFieldConfig["contextMenuActions"] = (
} else {
enqueueSnackbar("Cell evaluated", { variant: "success" });
}
} catch (error) {
enqueueSnackbar(`Failed: ${error}`, { variant: "error" });
} catch (error: any) {
if (error.message === "Failed to fetch") {
enqueueSnackbar(
"Evaluation failed. Rowy Run is likely out of memory. Please allocate more in GCP console.",
{
variant: "warning",
persist: true,
action: (snackbarId) => (
<Button
href={`https://console.cloud.google.com/run/deploy/${
projectSettings.rowyRunRegion ?? "us-central1"
}/rowy-backend?project=${projectId}`}
target="_blank"
rel="noopener noreferrer"
onClick={() => closeSnackbar(snackbarId)}
variant="contained"
color="secondary"
>
Open GCP Console <InlineOpenInNewIcon />
</Button>
),
}
);
} else {
enqueueSnackbar(`Failed: ${error}`, { variant: "error" });
}
}
};
const isEmpty =

View File

@@ -65,16 +65,28 @@ export default function Settings({
: config.derivativeFn
? config.derivativeFn
: config?.script
? `const derivative:Derivative = async ({row,ref,db,storage,auth})=>{
${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")}
}`
: `const derivative:Derivative = async ({row,ref,db,storage,auth})=>{
// Write your derivative code here
// for example:
// const sum = row.a + row.b;
// return sum;
// checkout the documentation for more info: https://docs.rowy.io/field-types/derivative
}`;
? `const derivative:Derivative = async ({row,ref,db,storage,auth,logging})=>{
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("derivative started")
// Import any NPM package needed
// const lodash = require('lodash');
${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")}
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`
: `const derivative:Derivative = async ({row,ref,db,storage,auth,logging})=>{
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("derivative started")
// Import any NPM package needed
// const lodash = require('lodash');
// Example:
// const sum = row.a + row.b;
// return sum;
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`;
return (
<>

View File

@@ -5,6 +5,7 @@ type DerivativeContext = {
db: FirebaseFirestore.Firestore;
auth: firebaseauth.BaseAuth;
change: any;
logging: RowyLogging;
};
type Derivative = (context: DerivativeContext) => "PLACEHOLDER_OUTPUT_TYPE";

View File

@@ -21,5 +21,6 @@ export const config: IFieldConfig = {
settings: Settings,
settingsValidator,
requireConfiguration: true,
requireCloudFunction: true,
};
export default config;

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

@@ -1,4 +1,3 @@
import { useCallback } from "react";
import { IEditorCellProps } from "@src/components/fields/types";
import { useSetAtom } from "jotai";
@@ -15,6 +14,15 @@ import { DATE_TIME_FORMAT } from "@src/constants/dates";
import { FileValue } from "@src/types/table";
import useFileUpload from "./useFileUpload";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
ResponderProvided,
} from "react-beautiful-dnd";
export default function File_({
column,
value,
@@ -25,11 +33,40 @@ export default function File_({
}: IEditorCellProps) {
const confirm = useSetAtom(confirmDialogAtom, projectScope);
const { loading, progress, handleDelete, localFiles, dropzoneState } =
useFileUpload(_rowy_ref, column.key, { multiple: true });
const {
loading,
progress,
handleDelete,
localFiles,
dropzoneState,
handleUpdate,
} = useFileUpload(_rowy_ref, column.key, { multiple: true });
const { isDragActive, getRootProps, getInputProps } = dropzoneState;
const dropzoneProps = getRootProps();
const onDragEnd = (result: DropResult, provided: ResponderProvided) => {
const { destination, source } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
const newValue = Array.from(value);
newValue.splice(source.index, 1);
newValue.splice(destination.index, 0, value[source.index]);
handleUpdate([...newValue]);
};
return (
<Stack
direction="row"
@@ -37,6 +74,8 @@ export default function File_({
sx={{
width: "100%",
height: "100%",
py: 0,
pl: 1,
...(isDragActive
? {
@@ -54,68 +93,110 @@ export default function File_({
tabIndex={tabIndex}
onClick={undefined}
>
<ChipList rowHeight={rowHeight}>
{Array.isArray(value) &&
value.map((file: FileValue) => (
<Grid
item
key={file.downloadURL}
style={
// Truncate so multiple files still visible
value.length > 1 ? { maxWidth: `calc(100% - 12px)` } : {}
}
>
<Tooltip
title={`File last modified ${format(
file.lastModifiedTS,
DATE_TIME_FORMAT
)}`}
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="image-droppable" direction="horizontal">
{(provided) => (
<ChipList rowHeight={rowHeight}>
<Grid
container
spacing={0.5}
wrap="nowrap"
ref={provided.innerRef}
{...provided.droppableProps}
>
<Chip
label={file.name}
icon={<FileIcon />}
sx={{
"& .MuiChip-label": {
lineHeight: 5 / 3,
},
}}
onClick={(e: any) => e.stopPropagation()}
component="a"
href={file.downloadURL}
target="_blank"
rel="noopener noreferrer"
clickable
onDelete={
disabled
? undefined
: () =>
confirm({
handleConfirm: () => handleDelete(file),
title: "Delete file?",
body: "This file cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
})
}
tabIndex={tabIndex}
style={{ width: "100%", cursor: "pointer" }}
/>
</Tooltip>
</Grid>
))}
{localFiles &&
localFiles.map((file) => (
<Grid item key={file.name}>
<Chip
icon={<FileIcon />}
label={file.name}
deleteIcon={
<CircularProgressOptical size={20} color="inherit" />
}
/>
</Grid>
))}
</ChipList>
{Array.isArray(value) &&
value.map((file: FileValue, i) => (
<Draggable
key={file.downloadURL}
draggableId={file.downloadURL}
index={i}
>
{(provided) => (
<Grid
item
ref={provided.innerRef}
{...provided.draggableProps}
style={{
display: "flex",
alignItems: "center",
// Truncate so multiple files still visible
maxWidth: `${
value.length > 1 ? "calc(100% - 12px)" : "initial"
}`,
...provided.draggableProps.style,
}}
>
{value.length > 1 && (
<div
{...provided.dragHandleProps}
style={{
display: "flex",
alignItems: "center",
}}
>
<DragIndicatorIcon />
</div>
)}
<Tooltip
title={`File last modified ${format(
file.lastModifiedTS,
DATE_TIME_FORMAT
)}`}
>
<Chip
label={file.name}
icon={<FileIcon />}
sx={{
"& .MuiChip-label": {
lineHeight: 5 / 3,
},
}}
onClick={(e: any) => e.stopPropagation()}
component="a"
href={file.downloadURL}
target="_blank"
rel="noopener noreferrer"
clickable
onDelete={
disabled
? undefined
: (e) => {
e.preventDefault();
confirm({
handleConfirm: () => handleDelete(file),
title: "Delete file?",
body: "This file cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
});
}
}
tabIndex={tabIndex}
style={{ width: "100%", cursor: "pointer" }}
/>
</Tooltip>
</Grid>
)}
</Draggable>
))}
</Grid>
{localFiles &&
localFiles.map((file) => (
<Grid item key={file.name}>
<Chip
icon={<FileIcon />}
label={file.name}
deleteIcon={
<CircularProgressOptical size={20} color="inherit" />
}
/>
</Grid>
))}
</ChipList>
)}
</Droppable>
</DragDropContext>
{!loading ? (
!disabled && (

View File

@@ -20,6 +20,15 @@ import { FileValue } from "@src/types/table";
import useFileUpload from "./useFileUpload";
import { FileIcon } from ".";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
ResponderProvided,
} from "react-beautiful-dnd";
export default function File_({
column,
_rowy_ref,
@@ -72,52 +81,94 @@ export default function File_({
</ButtonBase>
)}
<Grid container spacing={0.5} style={{ marginTop: 2 }}>
{Array.isArray(value) &&
value.map((file: FileValue) => (
<Grid item key={file.name}>
<Tooltip
title={`File last modified ${format(
file.lastModifiedTS,
DATE_TIME_FORMAT
)}`}
>
<div>
<Chip
icon={<FileIcon />}
label={file.name}
onClick={() => window.open(file.downloadURL)}
onDelete={
!disabled
? () =>
confirm({
title: "Delete file?",
body: "This file cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(file),
})
: undefined
}
/>
</div>
</Tooltip>
</Grid>
))}
<DragDropContext onDragEnd={() => console.log("onDragEnd")}>
<Droppable droppableId="sidebar-file-droppable">
{(provided) => (
<Grid
container
spacing={0.5}
style={{ marginTop: 2 }}
ref={provided.innerRef}
{...provided.droppableProps}
>
{Array.isArray(value) &&
value.map((file: FileValue, i) => (
<Draggable
key={file.downloadURL}
draggableId={file.downloadURL}
index={i}
>
{(provided) => (
<Grid
item
key={file.name}
ref={provided.innerRef}
{...provided.draggableProps}
style={{
display: "flex",
alignItems: "center",
...provided.draggableProps.style,
}}
>
{value.length > 1 && (
<div
{...provided.dragHandleProps}
style={{
display: "flex",
alignItems: "center",
}}
>
<DragIndicatorIcon />
</div>
)}
<Tooltip
title={`File last modified ${format(
file.lastModifiedTS,
DATE_TIME_FORMAT
)}`}
>
<div>
<Chip
icon={<FileIcon />}
label={file.name}
onClick={() => window.open(file.downloadURL)}
onDelete={
!disabled
? () =>
confirm({
title: "Delete file?",
body: "This file cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(file),
})
: undefined
}
/>
</div>
</Tooltip>
</Grid>
)}
</Draggable>
))}
{localFiles &&
localFiles.map((file) => (
<Grid item>
<Chip
icon={<FileIcon />}
label={file.name}
deleteIcon={
<CircularProgressOptical size={20} color="inherit" />
}
/>
{localFiles &&
localFiles.map((file) => (
<Grid item>
<Chip
icon={<FileIcon />}
label={file.name}
deleteIcon={
<CircularProgressOptical size={20} color="inherit" />
}
/>
</Grid>
))}
{provided.placeholder}
</Grid>
))}
</Grid>
)}
</Droppable>
</DragDropContext>
</>
);
}

View File

@@ -75,6 +75,15 @@ export default function useFileUpload(
[deleteUpload, docRef, fieldName, updateField]
);
// Drag and Drop
const handleUpdate = (files: any) => {
updateField({
path: docRef.path,
fieldName,
value: files,
});
};
return {
localFiles,
progress,
@@ -83,5 +92,6 @@ export default function useFileUpload(
handleUpload,
handleDelete,
dropzoneState,
handleUpdate,
};
}

View File

@@ -0,0 +1,27 @@
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,
ref: props._rowy_ref,
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,75 @@
import { Provider, useAtom } from "jotai";
import { currentUserAtom } from "@src/atoms/projectScope";
import {
tableRowsDbAtom,
tableScope,
tableSettingsAtom,
} from "@src/atoms/tableScope";
import TablePage from "@src/pages/Table/TablePage";
import { TableSchema } from "@src/types/table";
import { Box, InputLabel } from "@mui/material";
import TableSourcePreview from "./TableSourcePreview";
const PreviewTable = ({ tableSchema }: { tableSchema: TableSchema }) => {
const [currentUser] = useAtom(currentUserAtom, tableScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
return (
<Box>
<InputLabel>Preview table</InputLabel>
<Provider
key={"preview-table"}
scope={tableScope}
initialValues={[
[currentUserAtom, currentUser],
[tableSettingsAtom, tableSettings],
[tableRowsDbAtom, []],
]}
>
<TableSourcePreview tableSchema={tableSchema} />
<Box
sx={{
maxHeight: 300,
overflow: "auto",
marginTop: 1,
marginLeft: 0,
// table toolbar
"& > div:first-child": {
display: "none",
},
// table grid
"& > div:nth-of-type(2)": {
height: "unset",
},
// emtpy state
"& .empty-state": {
display: "none",
},
// column actions - add column
'& [data-col-id="_rowy_column_actions"]': {
display: "none",
},
// row headers - sort by, column settings
'& [data-row-id="_rowy_header"] > button': {
display: "none",
},
// row headers - drag handler
'& [data-row-id="_rowy_header"] > .column-drag-handle': {
display: "none !important",
},
// row headers - resize handler
'& [data-row-id="_rowy_header"] >:last-child': {
display: "none !important",
},
}}
>
<TablePage disableModals={true} disableSideDrawer={true} />
</Box>
</Provider>
</Box>
);
};
export default PreviewTable;

View File

@@ -0,0 +1,169 @@
import { lazy, Suspense, useMemo } from "react";
import { useDebouncedCallback } from "use-debounce";
import { useAtom } from "jotai";
import MultiSelect from "@rowy/multiselect";
import { Grid, InputLabel, Stack, FormHelperText } from "@mui/material";
import {
tableColumnsOrderedAtom,
tableSchemaAtom,
tableScope,
} from "@src/atoms/tableScope";
import FieldSkeleton from "@src/components/SideDrawer/FieldSkeleton";
import { ISettingsProps } from "@src/components/fields/types";
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
import { DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT } from "@src/components/Table";
import { ColumnConfig } from "@src/types/table";
import { defaultFn, listenerFieldTypes, outputFieldTypes } from "./util";
import PreviewTable from "./PreviewTable";
import { getFieldProp } from "..";
/* eslint-disable import/no-webpack-loader-syntax */
import formulaDefs from "!!raw-loader!./formula.d.ts";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper";
const CodeEditor = lazy(
() =>
import("@src/components/CodeEditor" /* webpackChunkName: "CodeEditor" */)
);
const diagnosticsOptions = {
noSemanticValidation: false,
noSyntaxValidation: false,
noSuggestionDiagnostics: true,
};
export default function Settings({
config,
fieldName,
onChange,
onBlur,
errors,
}: ISettingsProps) {
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const returnType = getFieldProp("dataType", config.renderFieldType) ?? "any";
const formulaFn = config?.formulaFn ? config.formulaFn : defaultFn;
const previewTableSchema = useMemo(() => {
const columns = tableSchema.columns || {};
return {
...tableSchema,
columns: Object.keys(columns).reduce((previewSchema, key) => {
if ((config.listenerFields || []).includes(columns[key].fieldName)) {
previewSchema[key] = {
...columns[key],
fixed: false,
width: DEFAULT_COL_WIDTH,
};
}
if (columns[key].fieldName === fieldName) {
previewSchema[key] = {
...columns[key],
config,
fixed: true,
};
}
return previewSchema;
}, {} as { [key: string]: ColumnConfig }),
rowHeight: DEFAULT_ROW_HEIGHT,
};
}, [config, fieldName, tableSchema]);
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>
<CodeEditorHelper
disableDefaultVariables
disableSecretManagerLink
disableCloudManagerLink
docLink={WIKI_LINKS.fieldTypesFormula}
additionalVariables={[
{
key: "row",
description: `row has the value of doc.data() it has type definitions using this table's schema, but you can only access formula's listener fields.`,
},
{
key: "ref",
description: `reference object that holds the readonly reference of the row document.(i.e ref.id)`,
},
]}
/>
<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>
<PreviewTable tableSchema={previewTableSchema} />
</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,83 @@
import { useCallback, useEffect } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useAtomCallback } from "jotai/utils";
import { cloneDeep, findIndex, sortBy } from "lodash-es";
import {
_deleteRowDbAtom,
_updateRowDbAtom,
tableNextPageAtom,
tableRowsDbAtom,
tableSchemaAtom,
tableScope,
tableSettingsAtom,
} from "@src/atoms/tableScope";
import { TableRow, TableSchema } from "@src/types/table";
import { updateRowData } from "@src/utils/table";
import { serializeRef } from "./util";
const TableSourcePreview = ({ tableSchema }: { tableSchema: TableSchema }) => {
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const setTableSchemaAtom = useSetAtom(tableSchemaAtom, tableScope);
const setRows = useSetAtom(tableRowsDbAtom, tableScope);
useEffect(() => {
setRows(
["preview-doc-1", "preview-doc-2", "preview-doc-3"].map((docId) => ({
_rowy_ref: serializeRef(`${tableSettings.collection}/${docId}`),
}))
);
}, [setRows, tableSettings.collection]);
useEffect(() => {
setTableSchemaAtom(() => ({
...tableSchema,
_rowy_ref: "preview",
}));
}, [tableSchema, setTableSchemaAtom]);
const readRowsDb = useAtomCallback(
useCallback((get) => get(tableRowsDbAtom) || [], []),
tableScope
);
const setUpdateRowDb = useSetAtom(_updateRowDbAtom, tableScope);
setUpdateRowDb(() => async (path: string, update: Partial<TableRow>) => {
const rows = await readRowsDb();
const index = findIndex(rows, ["_rowy_ref.path", path]);
if (index === -1) {
setRows(
sortBy(
[
...rows,
{ ...update, _rowy_ref: { id: path.split("/").pop()!, path } },
],
["_rowy_ref.id"]
)
);
} else {
const updatedRows = [...rows];
updatedRows[index] = cloneDeep(rows[index]);
updatedRows[index] = updateRowData(updatedRows[index], update);
setRows(updatedRows);
}
return Promise.resolve();
});
const setDeleteRowDb = useSetAtom(_deleteRowDbAtom, tableScope);
setDeleteRowDb(() => async (path: string) => {
const rows = await readRowsDb();
const index = findIndex(rows, ["_rowy_ref.path", path]);
if (index > -1) {
setRows(rows.filter((_, idx) => idx !== index));
}
return Promise.resolve();
});
const setNextPageAtom = useSetAtom(tableNextPageAtom, tableScope);
setNextPageAtom({ loading: false, available: false });
return null;
};
export default TableSourcePreview;

View File

@@ -0,0 +1,9 @@
type RowRef<T> = { id: string; path: string; parent: T };
interface Ref extends RowRef<Ref> {}
type FormulaContext = {
row: Row;
ref: Ref;
};
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,82 @@
import { useEffect, useMemo, useState } from "react";
import { pick, zipObject } from "lodash-es";
import { useAtom } from "jotai";
import { TableRow, TableRowRef } from "@src/types/table";
import { tableColumnsOrderedAtom, tableScope } from "@src/atoms/tableScope";
import {
listenerFieldTypes,
serializeRef,
useDeepCompareMemoize,
} from "./util";
export const useFormula = ({
row,
ref,
listenerFields,
formulaFn,
}: {
row: TableRow;
ref: TableRowRef;
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(
JSON.stringify({
formulaFn,
row: availableFields,
ref: serializeRef(ref.path),
})
);
return () => {
worker.terminate();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [useDeepCompareMemoize(listeners), formulaFn]);
return { result, error, loading };
};

View File

@@ -0,0 +1,144 @@
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";
import { TableRowRef } from "@src/types/table";
import { DocumentData, DocumentReference } from "firebase/firestore";
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, ref })=> {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
// Example:
// return row.a + row.b;
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}
`;
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;
}
};
export const serializeRef = (path: string, maxDepth = 20) => {
const pathArr = path.split("/");
const serializedRef = {
path: pathArr.join("/"),
id: pathArr.pop(),
} as any;
let curr: TableRowRef | Partial<DocumentReference<DocumentData>> =
serializedRef;
let depth = 0;
while (pathArr.length > 0 && curr && depth < maxDepth) {
(curr.parent as any) = {
path: pathArr.join("/"),
id: pathArr.pop(),
} as Partial<DocumentReference<DocumentData>>;
curr = curr.parent as any;
maxDepth++;
}
return serializedRef;
};

View File

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

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { IEditorCellProps } from "@src/components/fields/types";
import { useAtom, useSetAtom } from "jotai";
import { useSetAtom } from "jotai";
import { assignIn } from "lodash-es";
import { alpha, Box, Stack, Grid, IconButton, ButtonBase } from "@mui/material";
@@ -11,13 +11,20 @@ import Thumbnail from "@src/components/Thumbnail";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
import { tableSchemaAtom, tableScope } from "@src/atoms/tableScope";
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
import { FileValue } from "@src/types/table";
import useFileUpload from "@src/components/fields/File/useFileUpload";
import { IMAGE_MIME_TYPES } from "./index";
import { imgSx, thumbnailSx, deleteImgHoverSx } from "./DisplayCell";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
ResponderProvided,
} from "react-beautiful-dnd";
export default function Image_({
column,
value,
@@ -28,11 +35,17 @@ export default function Image_({
}: IEditorCellProps) {
const confirm = useSetAtom(confirmDialogAtom, projectScope);
const { loading, progress, handleDelete, localFiles, dropzoneState } =
useFileUpload(_rowy_ref, column.key, {
multiple: true,
accept: IMAGE_MIME_TYPES,
});
const {
loading,
progress,
handleDelete,
localFiles,
dropzoneState,
handleUpdate,
} = useFileUpload(_rowy_ref, column.key, {
multiple: true,
accept: IMAGE_MIME_TYPES,
});
const localImages = useMemo(
() =>
@@ -45,6 +58,28 @@ export default function Image_({
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
const dropzoneProps = getRootProps();
const onDragEnd = (result: DropResult, provided: ResponderProvided) => {
const { destination, source } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
const newValue = Array.from(value);
newValue.splice(source.index, 1);
newValue.splice(destination.index, 0, value[source.index]);
handleUpdate([...newValue]);
};
let thumbnailSize = "100x100";
if (rowHeight > 50) thumbnailSize = "200x200";
if (rowHeight > 100) thumbnailSize = "400x400";
@@ -84,62 +119,102 @@ export default function Image_({
marginLeft: "0 !important",
}}
>
<Grid container spacing={0.5} wrap="nowrap">
{Array.isArray(value) &&
value.map((file: FileValue, i) => (
<Grid item key={file.downloadURL}>
<ButtonBase
aria-label="Delete…"
sx={imgSx(rowHeight)}
className="img"
onClick={() => {
confirm({
title: "Delete image?",
body: "This image cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(file),
});
}}
disabled={disabled}
tabIndex={tabIndex}
>
<Thumbnail
imageUrl={file.downloadURL}
size={thumbnailSize}
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={deleteImgHoverSx}
>
<DeleteIcon color="error" />
</Grid>
</ButtonBase>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="image-droppable" direction="horizontal">
{(provided) => (
<Grid
container
spacing={0.5}
wrap="nowrap"
ref={provided.innerRef}
{...provided.droppableProps}
>
{Array.isArray(value) &&
value.map((file: FileValue, i) => (
<Draggable
key={file.downloadURL}
draggableId={file.downloadURL}
index={i}
>
{(provided) => (
<Grid
item
ref={provided.innerRef}
{...provided.draggableProps}
style={{
display: "flex",
alignItems: "center",
...provided.draggableProps.style,
}}
>
{value.length > 1 && (
<div
{...provided.dragHandleProps}
style={{
display: "flex",
alignItems: "center",
}}
>
<DragIndicatorIcon />
</div>
)}
<ButtonBase
aria-label="Delete…"
sx={imgSx(rowHeight)}
className="img"
onClick={() => {
confirm({
title: "Delete image?",
body: "This image cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(file),
});
}}
disabled={disabled}
tabIndex={tabIndex}
>
<Thumbnail
imageUrl={file.downloadURL}
size={thumbnailSize}
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={deleteImgHoverSx}
>
<DeleteIcon color="error" />
</Grid>
</ButtonBase>
</Grid>
)}
</Draggable>
))}
{localImages &&
localImages.map((image) => (
<Grid item>
<Box
sx={[
imgSx(rowHeight),
{
boxShadow: (theme) =>
`0 0 0 1px ${theme.palette.divider} inset`,
},
]}
style={{
backgroundImage: `url("${image.localURL}")`,
}}
/>
</Grid>
))}
{provided.placeholder}
</Grid>
))}
{localImages &&
localImages.map((image) => (
<Grid item>
<Box
sx={[
imgSx(rowHeight),
{
boxShadow: (theme) =>
`0 0 0 1px ${theme.palette.divider} inset`,
},
]}
style={{
backgroundImage: `url("${image.localURL}")`,
}}
/>
</Grid>
))}
</Grid>
)}
</Droppable>
</DragDropContext>
</div>
{!loading ? (

View File

@@ -26,6 +26,15 @@ import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
import useFileUpload from "@src/components/fields/File/useFileUpload";
import { IMAGE_MIME_TYPES } from ".";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
ResponderProvided,
} from "react-beautiful-dnd";
const imgSx = {
position: "relative",
width: 80,
@@ -94,6 +103,7 @@ export default function Image_({
uploaderState,
localFiles,
dropzoneState,
handleUpdate,
} = useFileUpload(_rowy_ref, column.key, {
multiple: true,
accept: IMAGE_MIME_TYPES,
@@ -109,6 +119,28 @@ export default function Image_({
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
const onDragEnd = (result: DropResult, provided: ResponderProvided) => {
const { destination, source } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
const newValue = Array.from(value);
newValue.splice(source.index, 1);
newValue.splice(destination.index, 0, value[source.index]);
handleUpdate([...newValue]);
};
return (
<>
{!disabled && (
@@ -151,112 +183,158 @@ export default function Image_({
</ButtonBase>
)}
<Grid container spacing={1} style={{ marginTop: 0 }}>
{Array.isArray(value) &&
value.map((image: FileValue) => (
<Grid item key={image.name}>
{disabled ? (
<Tooltip title="Open">
<ButtonBase
sx={imgSx}
onClick={() => window.open(image.downloadURL, "_blank")}
className="img"
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sidebar-image-droppable" direction="horizontal">
{(provided) => (
<Grid
container
spacing={1}
style={{ marginTop: 0 }}
ref={provided.innerRef}
{...provided.droppableProps}
>
{Array.isArray(value) &&
value.map((image: FileValue, i) => (
<Draggable
key={image.downloadURL}
draggableId={image.downloadURL}
index={i}
>
<Thumbnail
imageUrl={image.downloadURL}
size="200x200"
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={[overlaySx, deleteImgHoverSx]}
{(provided) => (
<Grid item>
{disabled ? (
<Tooltip title="Open">
<ButtonBase
sx={imgSx}
onClick={() =>
window.open(image.downloadURL, "_blank")
}
className="img"
>
<Thumbnail
imageUrl={image.downloadURL}
size="200x200"
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={[overlaySx, deleteImgHoverSx]}
>
{disabled ? (
<OpenIcon />
) : (
<DeleteIcon color="error" />
)}
</Grid>
</ButtonBase>
</Tooltip>
) : (
<div
ref={provided.innerRef}
{...provided.draggableProps}
style={{
display: "flex",
alignItems: "center",
...provided.draggableProps.style,
}}
>
{value.length > 1 && (
<div
{...provided.dragHandleProps}
style={{
display: "flex",
alignItems: "center",
}}
>
<DragIndicatorIcon />
</div>
)}
<Box sx={imgSx} className="img">
<Thumbnail
imageUrl={image.downloadURL}
size="200x200"
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={[overlaySx, deleteImgHoverSx]}
>
<Tooltip title="Delete…">
<IconButton
onClick={() =>
confirm({
title: "Delete image?",
body: "This image cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () =>
handleDelete(image),
})
}
>
<DeleteIcon color="error" />
</IconButton>
</Tooltip>
<Tooltip title="Open">
<IconButton
onClick={() =>
window.open(image.downloadURL, "_blank")
}
>
<OpenIcon />
</IconButton>
</Tooltip>
</Grid>
</Box>
</div>
)}
</Grid>
)}
</Draggable>
))}
{localImages &&
localImages.map((image) => (
<Grid item key={image.name}>
<ButtonBase
sx={imgSx}
style={{
backgroundImage: `url("${image.localURL}")`,
}}
className="img"
>
{disabled ? <OpenIcon /> : <DeleteIcon color="error" />}
</Grid>
</ButtonBase>
</Tooltip>
) : (
<div>
<Box sx={imgSx} className="img">
<Thumbnail
imageUrl={image.downloadURL}
size="200x200"
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={[overlaySx, deleteImgHoverSx]}
>
<Tooltip title="Delete…">
<IconButton
onClick={() =>
confirm({
title: "Delete image?",
body: "This image cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(image),
})
}
{uploaderState[image.name] && (
<Grid
container
justifyContent="center"
alignItems="center"
sx={overlaySx}
>
<DeleteIcon color="error" />
</IconButton>
</Tooltip>
<Tooltip title="Open">
<IconButton
onClick={() =>
window.open(image.downloadURL, "_blank")
}
>
<OpenIcon />
</IconButton>
</Tooltip>
</Grid>
</Box>
</div>
)}
</Grid>
))}
{localImages &&
localImages.map((image) => (
<Grid item key={image.name}>
<ButtonBase
sx={imgSx}
style={{
backgroundImage: `url("${image.localURL}")`,
}}
className="img"
>
{uploaderState[image.name] && (
<Grid
container
justifyContent="center"
alignItems="center"
sx={overlaySx}
>
<CircularProgressOptical
color="inherit"
size={48}
variant={
uploaderState[image.name].progress === 0
? "indeterminate"
: "determinate"
}
value={uploaderState[image.name].progress}
/>
<CircularProgressOptical
color="inherit"
size={48}
variant={
uploaderState[image.name].progress === 0
? "indeterminate"
: "determinate"
}
value={uploaderState[image.name].progress}
/>
</Grid>
)}
</ButtonBase>
</Grid>
)}
</ButtonBase>
))}
{provided.placeholder}
</Grid>
))}
</Grid>
)}
</Droppable>
</DragDropContext>
</>
);
}

View File

@@ -2,6 +2,7 @@ import { IDisplayCellProps } from "@src/components/fields/types";
import { useTheme } from "@mui/material";
import { resultColorsScale } from "@src/utils/color";
import { multiply100WithPrecision } from "./utils";
export default function Percentage({ column, value }: IDisplayCellProps) {
const theme = useTheme();
@@ -34,7 +35,7 @@ export default function Percentage({ column, value }: IDisplayCellProps) {
zIndex: 1,
}}
>
{Math.round(percentage * 100)}%
{multiply100WithPrecision(percentage)}%
</div>
</>
);

View File

@@ -1,13 +1,20 @@
import type { IEditorCellProps } from "@src/components/fields/types";
import EditorCellTextField from "@src/components/Table/TableCell/EditorCellTextField";
import { multiply100WithPrecision, divide100WithPrecision } from "./utils";
export default function Percentage(props: IEditorCellProps<number>) {
return (
<EditorCellTextField
{...(props as any)}
InputProps={{ type: "number", endAdornment: "%" }}
value={typeof props.value === "number" ? props.value * 100 : props.value}
onChange={(v) => props.onChange(Number(v) / 100)}
value={
typeof props.value === "number"
? multiply100WithPrecision(props.value)
: props.value
}
onChange={(v) => {
props.onChange(divide100WithPrecision(Number(v)));
}}
/>
);
}

View File

@@ -16,6 +16,7 @@ import { ISettingsProps } from "@src/components/fields/types";
import { Color, toColor } from "react-color-palette";
import { fieldSx } from "@src/components/SideDrawer/utils";
import { resultColorsScale, defaultColors } from "@src/utils/color";
import { multiply100WithPrecision } from "./utils";
const colorLabels: { [key: string]: string } = {
0: "Start",
@@ -160,7 +161,7 @@ const Preview = ({ colors }: { colors: any }) => {
}}
/>
<Typography style={{ position: "relative", zIndex: 1 }}>
{Math.floor(value * 100)}%
{multiply100WithPrecision(value)}%
</Typography>
</Box>
);

View File

@@ -3,6 +3,7 @@ import { ISideDrawerFieldProps } from "@src/components/fields/types";
import { TextField, InputAdornment, Box, useTheme } from "@mui/material";
import { resultColorsScale } from "@src/utils/color";
import { getFieldId } from "@src/components/SideDrawer/utils";
import { multiply100WithPrecision } from "./utils";
export default function Percentage({
column,
@@ -20,7 +21,9 @@ export default function Percentage({
margin="none"
onChange={(e) => onChange(Number(e.target.value) / 100)}
onBlur={onSubmit}
value={typeof value === "number" ? value * 100 : value}
value={
typeof value === "number" ? multiply100WithPrecision(value) : value
}
id={getFieldId(column.key)}
label=""
hiddenLabel

View File

@@ -0,0 +1,126 @@
import { trim, trimEnd } from "lodash-es";
/**
* Multiply a number by 100 and return a string without floating point error
* by shifting the decimal point 2 places to the right as a string
* e.g. floating point error: 0.07 * 100 === 7.000000000000001
*
* A few examples:
*
* let number = 0.07;
* console.log(number, multiply100WithPrecision(number));
* --> 7
*
* number = 0;
* console.log(number, multiply100WithPrecision(number));
* --> 0
*
* number = 0.1;
* console.log(number, multiply100WithPrecision(number));
* --> 10
*
* number = 0.001;
* console.log(number, multiply100WithPrecision(number));
* --> 0.1
*
* number = 0.00001;
* console.log(number, multiply100WithPrecision(number));
* --> 0.001
*
* number = 100;
* console.log(number, multiply100WithPrecision(number));
* --> 10000
*
* number = 1999.99;
* console.log(number, multiply100WithPrecision(number));
* --> 199999
*
* number = 1999.999;
* console.log(number, multiply100WithPrecision(number));
* --> 199999.9
*
* number = 0.25;
* console.log(number, multiply100WithPrecision(number));
* --> 25
*
* number = 0.15;
* console.log(number, multiply100WithPrecision(number));
* --> 15
*
* number = 1.23456789;
* console.log(number, multiply100WithPrecision(number));
* --> 123.456789
*
* number = 0.0000000001;
* console.log(number, multiply100WithPrecision(number));
* --> 1e-8
*/
export const multiply100WithPrecision = (value: number) => {
if (value === 0) {
return 0;
}
let valueString = value.toString();
// e.g 1e-10 becomes 1e-8
if (valueString.includes("e")) {
return value * 100;
}
// if the number is integer, add .00
if (!valueString.includes(".")) {
valueString = valueString.concat(".00");
}
let [before, after] = valueString.split(".");
// if after decimal has only 1 digit, pad a 0
if (after.length === 1) {
after = after.concat("0");
}
let newNumber = `${before}${after.slice(0, 2)}.${after.slice(2)}`;
newNumber = trimEnd(trim(newNumber, "0"), ".");
if (newNumber.startsWith(".")) {
newNumber = "0" + newNumber;
}
return Number(newNumber);
};
/**
* Divide a number by 100 and return a string without floating point error
* by shifting the decimal point 2 places to the left as a string
*/
export const divide100WithPrecision = (value: number) => {
if (value === 0) {
return 0;
}
let valueString = value.toString();
// e.g 1e-10 becomes 1e-8
if (valueString.includes("e")) {
return value / 100;
}
// add decimal if integer
if (!valueString.includes(".")) {
valueString = valueString + ".";
}
let [before, after] = valueString.split(".");
// if before decimal has less than digit, pad 0
if (before.length < 2) {
before = "00" + before;
}
let newNumber = `${before.slice(0, before.length - 2)}.${before.slice(
before.length - 2
)}${after}`;
newNumber = trimEnd(trimEnd(newNumber, "0"), ".");
if (newNumber.startsWith(".")) {
newNumber = "0" + newNumber;
}
return Number(newNumber);
};

View File

@@ -0,0 +1,6 @@
import { DocumentReference } from "@google-cloud/firestore";
export const valueFormatter = (value: DocumentReference, operator: string) => {
if (value && value.path) return value.path;
return "";
};

View File

@@ -6,6 +6,7 @@ import { Reference } from "@src/assets/icons";
import DisplayCell from "./DisplayCell";
import EditorCell from "./EditorCell";
import { filterOperators } from "@src/components/fields/ShortText/Filter";
import { valueFormatter } from "./filters";
const SideDrawerField = lazy(
() =>
@@ -27,6 +28,6 @@ export const config: IFieldConfig = {
disablePadding: true,
}),
SideDrawerField,
filter: { operators: filterOperators },
filter: { operators: filterOperators, valueFormatter: valueFormatter },
};
export default config;

View File

@@ -18,6 +18,24 @@ import ColorSelect, {
SelectColorThemeOptions,
} from "@src/components/SelectColors";
import {
DragDropContext,
Draggable,
DraggingStyle,
Droppable,
NotDraggingStyle,
} from "react-beautiful-dnd";
import DragIndicatorOutlinedIcon from "@mui/icons-material/DragIndicatorOutlined";
const getItemStyle = (
isDragging: boolean,
draggableStyle: DraggingStyle | NotDraggingStyle | undefined
) => ({
backgroundColor: isDragging ? "rgba(255, 255, 255, 0.08)" : "",
borderRadius: "4px",
...draggableStyle,
});
export default function Settings({ onChange, config }: ISettingsProps) {
const listEndRef: any = useRef(null);
const options = config.options ?? [];
@@ -53,6 +71,13 @@ export default function Settings({ onChange, config }: ISettingsProps) {
onChange("colors")(Object(colors));
};
const handleOnDragEnd = (result: any) => {
if (!result.destination) return;
const [removed] = options.splice(result.source.index, 1);
options.splice(result.destination.index, 0, removed);
onChange("options")([...options]);
};
return (
<div>
<InputLabel>Options</InputLabel>
@@ -64,42 +89,82 @@ export default function Settings({ onChange, config }: ISettingsProps) {
marginBottom: 5,
}}
>
{options?.map((option: string, index: number) => (
<>
<Grid
container
direction="row"
key={`option-${option}`}
justifyContent="space-between"
alignItems="center"
>
<Grid item>
<Grid container direction="row" alignItems="center" gap={2}>
<ColorSelect
initialValue={colors[option.toLocaleLowerCase()]}
handleChange={(color) =>
handleChipColorChange(option, color)
}
/>
<Typography>{option}</Typography>
</Grid>
</Grid>
<Grid item spacing={2}>
<IconButton
aria-label="Remove"
onClick={() =>
onChange("options")(
options.filter((o: string) => o !== option)
)
}
>
{<RemoveIcon />}
</IconButton>
</Grid>
</Grid>
<Divider />
</>
))}
<DragDropContext onDragEnd={handleOnDragEnd}>
<Droppable droppableId="options_manager" direction="vertical">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{options?.map((option: string, index: number) => (
<Draggable key={option} draggableId={option} index={index}>
{(provided, snapshot) => (
<>
<Grid
ref={provided.innerRef}
{...provided.draggableProps}
style={getItemStyle(
snapshot.isDragging,
provided.draggableProps.style
)}
container
direction="row"
key={`option-${option}`}
justifyContent="space-between"
alignItems="center"
>
<Grid
{...provided.dragHandleProps}
item
sx={{ display: "flex" }}
>
<DragIndicatorOutlinedIcon
color="disabled"
sx={[
{
marginRight: "6px",
},
]}
/>
<Grid item>
<Grid
container
direction="row"
alignItems="center"
gap={2}
>
<ColorSelect
initialValue={
colors[option.toLocaleLowerCase()]
}
handleChange={(color) =>
handleChipColorChange(option, color)
}
/>
<Typography>{option}</Typography>
</Grid>
</Grid>
</Grid>
<Grid item>
<IconButton
aria-label="Remove"
onClick={() =>
onChange("options")(
options.filter((o: string) => o !== option)
)
}
>
{<RemoveIcon />}
</IconButton>
</Grid>
</Grid>
<Divider />
</>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<div ref={listEndRef} style={{ height: 40 }} />
</div>
@@ -123,7 +188,7 @@ export default function Settings({ onChange, config }: ISettingsProps) {
onChange={(e) => {
setNewOption(e.target.value);
}}
onKeyDown={(e: any) => {
onKeyPress={(e: any) => {
if (e.key === "Enter") {
handleAdd();
}

View File

@@ -1,30 +1,111 @@
import { useAtom } from "jotai";
import { Avatar, AvatarGroup, ButtonBase, Stack, Tooltip } from "@mui/material";
import { allUsersAtom, projectScope } from "@src/atoms/projectScope";
import { IDisplayCellProps } from "@src/components/fields/types";
import { ChevronDown } from "@src/assets/icons/ChevronDown";
import { UserDataType } from "./UserSelect";
import { Tooltip, Stack, Avatar } from "@mui/material";
export default function User({
value,
showPopoverCell,
disabled,
tabIndex,
}: IDisplayCellProps) {
const [users] = useAtom(allUsersAtom, projectScope);
import { format } from "date-fns";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
let userValue: UserDataType[] = [];
let emails = new Set();
export default function User({ value, column }: IDisplayCellProps) {
if (!value || !value.displayName) return null;
if (value !== undefined && value !== null) {
if (!Array.isArray(value)) {
value = [value.email];
}
for (const user of users) {
if (user.user && user.user?.email && value.includes(user.user.email)) {
if (!emails.has(user.user.email)) {
emails.add(user.user.email);
userValue.push(user.user);
}
}
}
}
const chip = (
<Stack spacing={0.75} direction="row" alignItems="center">
<Avatar
alt="Avatar"
src={value.photoURL}
style={{ width: 20, height: 20 }}
/>
<span>{value.displayName}</span>
if (userValue.length === 0) {
return (
<ButtonBase
onClick={() => showPopoverCell(true)}
style={{
width: "100%",
height: "100%",
font: "inherit",
color: "inherit !important",
letterSpacing: "inherit",
textAlign: "inherit",
justifyContent: "flex-end",
}}
tabIndex={tabIndex}
>
<ChevronDown className="row-hover-iconButton end" />
</ButtonBase>
);
}
const rendered = (
<Stack
spacing={0.75}
direction="row"
alignItems="center"
style={{
flexGrow: 1,
overflow: "hidden",
paddingLeft: "var(--cell-padding)",
}}
>
{userValue.length > 1 ? (
<AvatarGroup
sx={{
"& .MuiAvatar-root": { width: 20, height: 20, fontSize: 12 },
}}
max={5}
>
{userValue.map((user: UserDataType) => (
<Tooltip title={`${user.displayName}(${user.email})`}>
<Avatar alt={user.displayName} src={user.photoURL} />
</Tooltip>
))}
</AvatarGroup>
) : (
<>
<Avatar
alt="Avatar"
src={userValue[0].photoURL}
style={{ width: 20, height: 20 }}
/>
<span>{userValue[0].displayName}</span>
</>
)}
</Stack>
);
if (!value.timestamp) return chip;
const dateLabel = format(
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
column.config?.format || DATE_TIME_FORMAT
if (disabled) {
return rendered;
}
return (
<ButtonBase
onClick={() => showPopoverCell(true)}
style={{
width: "100%",
height: "100%",
font: "inherit",
color: "inherit !important",
letterSpacing: "inherit",
textAlign: "inherit",
justifyContent: "flex-start",
}}
tabIndex={tabIndex}
>
{rendered}
<ChevronDown className="row-hover-iconButton end" />
</ButtonBase>
);
return <Tooltip title={dateLabel}>{chip}</Tooltip>;
}

View File

@@ -0,0 +1,6 @@
import { IEditorCellProps } from "@src/components/fields/types";
import UserSelect from "./UserSelect";
export default function EditorCell({ ...props }: IEditorCellProps) {
return <UserSelect {...props} />;
}

View File

@@ -0,0 +1,25 @@
import { Typography, FormControlLabel, Checkbox } from "@mui/material";
import { ISettingsProps } from "@src/components/fields/types";
export default function Settings({ onChange, config }: ISettingsProps) {
return (
<FormControlLabel
value="required"
label={
<>
Accept multiple value
<Typography variant="caption" color="text.secondary" display="block">
Make this column to support multiple values.
</Typography>
</>
}
control={
<Checkbox
checked={config?.multiple}
onChange={(e) => onChange("multiple")(e.target.checked)}
name="multiple"
/>
}
/>
);
}

View File

@@ -1,50 +1,101 @@
import { format } from "date-fns";
import { useRef, useState } from "react";
import { useAtom } from "jotai";
import { Tooltip, Stack, AvatarGroup, Avatar } from "@mui/material";
import { allUsersAtom, projectScope } from "@src/atoms/projectScope";
import { fieldSx } from "@src/components/SideDrawer/utils";
import { ChevronDown } from "@src/assets/icons/ChevronDown";
import { ISideDrawerFieldProps } from "@src/components/fields/types";
import UserSelect, { UserDataType } from "./UserSelect";
import { Box, Stack, Typography, Avatar } from "@mui/material";
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
export default function User({
export default function SideDrawerSelect({
column,
_rowy_ref,
value,
onDirty,
onChange,
onSubmit,
disabled,
}: ISideDrawerFieldProps) {
if (!value || !value.displayName || !value.timestamp)
return <Box sx={fieldSx} />;
const [open, setOpen] = useState(false);
const [users] = useAtom(allUsersAtom, projectScope);
const parentRef = useRef(null);
const dateLabel = value.timestamp
? format(
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
column.config?.format || DATE_TIME_FORMAT
)
: null;
let userValue: UserDataType[] = [];
let emails = new Set();
if (value !== undefined && value !== null) {
if (!Array.isArray(value)) {
value = [value.email];
}
for (const user of users) {
if (user.user && user.user?.email && value.includes(user.user.email)) {
if (!emails.has(user.user.email)) {
emails.add(user.user.email);
userValue.push(user.user);
}
}
}
}
return (
<Stack direction="row" sx={fieldSx} id={getFieldId(column.key)}>
<Avatar
alt="Avatar"
src={value.photoURL}
sx={{ width: 32, height: 32, ml: -0.5, mr: 1.5, my: 0.5 }}
/>
<Typography
variant="body2"
component="div"
style={{ whiteSpace: "normal" }}
<>
<Stack
ref={parentRef}
onClick={() => setOpen(true)}
direction="row"
sx={[
fieldSx,
{
alignItems: "center",
justifyContent: userValue.length > 0 ? "space-between" : "flex-end",
marginTop: "8px",
marginBottom: "8px",
},
]}
>
{value.displayName} ({value.email})
{dateLabel && (
<Typography variant="caption" color="textSecondary" component="div">
{dateLabel}
</Typography>
{userValue.length === 0 ? null : userValue.length > 1 ? (
<AvatarGroup
sx={{
"& .MuiAvatar-root": { width: 20, height: 20, fontSize: 12 },
}}
max={20}
>
{userValue.map(
(user: UserDataType) =>
user && (
<Tooltip title={`${user.displayName}(${user.email})`}>
<Avatar alt={user.displayName} src={user.photoURL} />
</Tooltip>
)
)}
</AvatarGroup>
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<Avatar
alt="Avatar"
src={userValue[0].photoURL}
style={{ width: 20, height: 20 }}
/>
<span>{userValue[0].displayName}</span>
</div>
)}
</Typography>
</Stack>
<ChevronDown className="row-hover-iconButton end" />
</Stack>
<UserSelect
open={open}
value={value}
onChange={onChange}
onSubmit={onSubmit}
parentRef={parentRef.current}
column={column}
showPopoverCell={setOpen}
disabled={disabled}
/>
</>
);
}

View File

@@ -0,0 +1,165 @@
import { useMemo } from "react";
import { useAtom } from "jotai";
import MultiSelect from "@rowy/multiselect";
import {
AutocompleteProps,
Avatar,
Box,
PopoverProps,
Stack,
} from "@mui/material";
import { createFilterOptions } from "@mui/material/Autocomplete";
import { projectScope, allUsersAtom } from "@src/atoms/projectScope";
import { ColumnConfig } from "@src/types/table";
export type UserDataType = {
email: string;
displayName?: string;
photoURL?: string;
phoneNumber?: string;
};
type UserOptionType = {
label: string;
value: string;
user: UserDataType;
};
interface IUserSelectProps<T = any> {
open?: boolean;
value: T;
onChange: (value: T) => void;
onSubmit: () => void;
parentRef?: PopoverProps["anchorEl"];
column: ColumnConfig;
disabled: boolean;
showPopoverCell: (value: boolean) => void;
}
export default function UserSelect({
open,
value,
onChange,
onSubmit,
parentRef,
column,
showPopoverCell,
disabled,
}: IUserSelectProps) {
const [users] = useAtom(allUsersAtom, projectScope);
const options = useMemo(() => {
let options: UserOptionType[] = [];
let emails = new Set();
for (const user of users) {
if (user.user && user.user?.email) {
if (!emails.has(user.user.email)) {
emails.add(user.user.email);
options.push({
label: user.user.email,
value: user.user.email,
user: user.user,
});
}
}
}
return options;
}, [users]);
const filterOptions = createFilterOptions({
trim: true,
ignoreCase: true,
matchFrom: "start",
stringify: (option: UserOptionType) => option.user.displayName || "",
});
const renderOption: AutocompleteProps<
UserOptionType,
false,
false,
false
>["renderOption"] = (props, option) => {
return <UserListItem user={option.user} {...props} />;
};
if (value === undefined || value === null) {
value = [];
} else if (!Array.isArray(value)) {
value = [value.email];
}
return (
<MultiSelect
value={value}
options={options}
label={column.name}
labelPlural={column.name}
multiple={column.config?.multiple || false}
onChange={(v: any) => {
if (typeof v === "string") {
v = [v];
}
onChange(v);
}}
disabled={disabled}
clearText="Clear"
doneText="Done"
{...{
AutocompleteProps: {
renderOption,
filterOptions,
},
}}
onClose={() => {
onSubmit();
showPopoverCell(false);
}}
// itemRenderer={(option: UserOptionType) => <UserListItem user={option.user} />}
TextFieldProps={{
style: { display: "none" },
SelectProps: {
open: open === undefined ? true : open,
MenuProps: {
anchorEl: parentRef || null,
anchorOrigin: { vertical: "bottom", horizontal: "center" },
transformOrigin: { vertical: "top", horizontal: "center" },
sx: {
"& .MuiPaper-root": { minWidth: `${column.width}px !important` },
},
},
},
}}
/>
);
}
const UserListItem = ({ user, ...props }: { user: UserDataType }) => {
return (
<li {...props}>
<Box sx={[{ position: "relative" }]}>
<Stack
spacing={0.75}
direction="row"
alignItems="center"
style={{ width: "100%" }}
>
<Avatar
alt="Avatar"
src={user.photoURL}
sx={{
width: 20,
height: 20,
fontSize: "inherit",
marginRight: "6px",
}}
>
{user.displayName ? user.displayName[0] : ""}
</Avatar>
<span>{user.displayName}</span>
</Stack>
</Box>
</li>
);
};

View File

@@ -4,14 +4,14 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import UserIcon from "@mui/icons-material/PersonOutlined";
import DisplayCell from "./DisplayCell";
import EditorCell from "./EditorCell";
const SideDrawerField = lazy(
() =>
import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-User" */)
);
const Settings = lazy(
() =>
import("../CreatedBy/Settings" /* webpackChunkName: "Settings-CreatedBy" */)
() => import("./Settings" /* webpackChunkName: "Settings-User" */)
);
export const config: IFieldConfig = {
@@ -23,7 +23,10 @@ export const config: IFieldConfig = {
initialValue: null,
icon: <UserIcon />,
description: "User information and optionally, timestamp. Read-only.",
TableCell: withRenderTableCell(DisplayCell, null),
TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
disablePadding: true,
transparentPopover: true,
}),
SideDrawerField,
settings: Settings,
};

View File

@@ -31,8 +31,10 @@ import ConnectTable from "./ConnectTable";
import ConnectService from "./ConnectService";
import Json from "./Json";
import Code from "./Code";
import Array from "./Array";
import Action from "./Action";
import Derivative from "./Derivative";
import Formula from "./Formula";
import Markdown from "./Markdown";
// // import Aggregate from "./Aggregate";
import Status from "./Status";
@@ -81,11 +83,14 @@ export const FIELDS: IFieldConfig[] = [
Json,
Code,
Markdown,
Array,
/** CLOUD FUNCTION */
Action,
Derivative,
// // Aggregate,
Status,
/** CLIENT FUNCTION */
Formula,
/** AUDITING */
CreatedBy,
UpdatedBy,

View File

@@ -19,6 +19,7 @@ export interface IFieldConfig {
dataType: string;
initializable?: boolean;
requireConfiguration?: boolean;
requireCloudFunction?: boolean;
initialValue: any;
icon?: React.ReactNode;
description?: string;