mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
Merge remote-tracking branch 'upstream/develop' into rowy-135
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
2
src/components/fields/Action/action.d.ts
vendored
2
src/components/fields/Action/action.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -30,6 +30,7 @@ export const config: IFieldConfig = {
|
||||
SideDrawerField,
|
||||
settings: Settings,
|
||||
requireConfiguration: true,
|
||||
requireCloudFunction: true,
|
||||
sortKey: "status",
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -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
|
||||
}`;
|
||||
|
||||
24
src/components/fields/Array/DisplayCell.tsx
Normal file
24
src/components/fields/Array/DisplayCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/components/fields/Array/SideDrawerField/AddButton.tsx
Normal file
92
src/components/fields/Array/SideDrawerField/AddButton.tsx
Normal 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;
|
||||
108
src/components/fields/Array/SideDrawerField/SupportedTypes.ts
Normal file
108
src/components/fields/Array/SideDrawerField/SupportedTypes.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
205
src/components/fields/Array/SideDrawerField/index.tsx
Normal file
205
src/components/fields/Array/SideDrawerField/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/components/fields/Array/SideDrawerField/utils.ts
Normal file
59
src/components/fields/Array/SideDrawerField/utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/components/fields/Array/index.tsx
Normal file
30
src/components/fields/Array/index.tsx
Normal 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;
|
||||
@@ -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)}
|
||||
|
||||
@@ -15,6 +15,7 @@ type ConnectorContext = {
|
||||
auth: firebaseauth.BaseAuth;
|
||||
query: string;
|
||||
user: ConnectorUser;
|
||||
logging: RowyLogging;
|
||||
};
|
||||
type ConnectorResult = any[];
|
||||
type Connector = (
|
||||
|
||||
@@ -34,6 +34,7 @@ export const config: IFieldConfig = {
|
||||
}),
|
||||
SideDrawerField,
|
||||
requireConfiguration: true,
|
||||
requireCloudFunction: true,
|
||||
settings: Settings,
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -5,6 +5,7 @@ type DerivativeContext = {
|
||||
db: FirebaseFirestore.Firestore;
|
||||
auth: firebaseauth.BaseAuth;
|
||||
change: any;
|
||||
logging: RowyLogging;
|
||||
};
|
||||
|
||||
type Derivative = (context: DerivativeContext) => "PLACEHOLDER_OUTPUT_TYPE";
|
||||
|
||||
@@ -21,5 +21,6 @@ export const config: IFieldConfig = {
|
||||
settings: Settings,
|
||||
settingsValidator,
|
||||
requireConfiguration: true,
|
||||
requireCloudFunction: true,
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
27
src/components/fields/Formula/DisplayCell.tsx
Normal file
27
src/components/fields/Formula/DisplayCell.tsx
Normal 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} />;
|
||||
}
|
||||
75
src/components/fields/Formula/PreviewTable.tsx
Normal file
75
src/components/fields/Formula/PreviewTable.tsx
Normal 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;
|
||||
169
src/components/fields/Formula/Settings.tsx
Normal file
169
src/components/fields/Formula/Settings.tsx
Normal 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;
|
||||
};
|
||||
83
src/components/fields/Formula/TableSourcePreview.ts
Normal file
83
src/components/fields/Formula/TableSourcePreview.ts
Normal 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;
|
||||
9
src/components/fields/Formula/formula.d.ts
vendored
Normal file
9
src/components/fields/Formula/formula.d.ts
vendored
Normal 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";
|
||||
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;
|
||||
82
src/components/fields/Formula/useFormula.tsx
Normal file
82
src/components/fields/Formula/useFormula.tsx
Normal 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 };
|
||||
};
|
||||
144
src/components/fields/Formula/util.tsx
Normal file
144
src/components/fields/Formula/util.tsx
Normal 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;
|
||||
};
|
||||
25
src/components/fields/Formula/worker.ts
Normal file
25
src/components/fields/Formula/worker.ts
Normal 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 {};
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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)));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
126
src/components/fields/Percentage/utils.ts
Normal file
126
src/components/fields/Percentage/utils.ts
Normal 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);
|
||||
};
|
||||
6
src/components/fields/Reference/filters.ts
Normal file
6
src/components/fields/Reference/filters.ts
Normal 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 "";
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
6
src/components/fields/User/EditorCell.tsx
Normal file
6
src/components/fields/User/EditorCell.tsx
Normal 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} />;
|
||||
}
|
||||
25
src/components/fields/User/Settings.tsx
Normal file
25
src/components/fields/User/Settings.tsx
Normal 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"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
165
src/components/fields/User/UserSelect.tsx
Normal file
165
src/components/fields/User/UserSelect.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface IFieldConfig {
|
||||
dataType: string;
|
||||
initializable?: boolean;
|
||||
requireConfiguration?: boolean;
|
||||
requireCloudFunction?: boolean;
|
||||
initialValue: any;
|
||||
icon?: React.ReactNode;
|
||||
description?: string;
|
||||
|
||||
Reference in New Issue
Block a user