Merge pull request #1179 from rowyio/rc

Rc
This commit is contained in:
Shams
2023-03-28 12:11:26 +02:00
committed by GitHub
124 changed files with 4858 additions and 1276 deletions

View File

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

View File

@@ -48,7 +48,7 @@ export const tablesAtom = atom<TableSettings[]>((get) => {
return sortBy(tables, "name")
.filter((table) =>
userRoles.includes("ADMIN") || Array.isArray(table.roles)
? table.roles.some((role) => userRoles.includes(role))
? table.roles?.some((role) => userRoles.includes(role))
: false
)
.map((table) => ({
@@ -105,7 +105,7 @@ export const deleteTableAtom = atom<
/** Stores a function to get a tables schema doc (without listener) */
export const getTableSchemaAtom = atom<
((id: string) => Promise<TableSchema>) | undefined
((id: string, withSubtables?: boolean) => Promise<TableSchema>) | undefined
>(undefined);
/** Roles used in the project based on table settings */

View File

@@ -73,7 +73,6 @@ export const rowyRunAtom = atom((get) => {
handleNotSetUp,
}: IRowyRunRequestProps): Promise<Response | any | false> => {
if (!currentUser) {
console.log("Rowy Run: Not signed in", route.path);
if (handleNotSetUp) handleNotSetUp();
return false;
}
@@ -85,7 +84,6 @@ export const rowyRunAtom = atom((get) => {
? rowyRunServices?.[service]
: rowyRunUrl;
if (!serviceUrl) {
console.log("Rowy Run: Not set up", route.path);
if (handleNotSetUp) handleNotSetUp();
return false;
}

View File

@@ -141,7 +141,7 @@ export const tableSettingsDialogSchemaAtom = atom(async (get) => {
const tableId = get(tableSettingsDialogIdAtom);
const getTableSchema = get(getTableSchemaAtom);
if (!tableId || !getTableSchema) return {} as TableSchema;
return getTableSchema(tableId);
return getTableSchema(tableId, true);
});
/** Open the Get Started checklist from anywhere */

View File

@@ -164,7 +164,7 @@ export const tableRowsDbAtom = atom<TableRow[]>([]);
/** Combine tableRowsLocal and tableRowsDb */
export const tableRowsAtom = atom<TableRow[]>((get) => {
const rowsDb = [...get(tableRowsDbAtom)];
const rowsDb = get(tableRowsDbAtom);
const rowsLocal = get(tableRowsLocalAtom);
// Optimization: create Map of rowsDb by path to index for faster lookup
@@ -178,15 +178,17 @@ export const tableRowsAtom = atom<TableRow[]>((get) => {
if (rowsDbMap.has(row._rowy_ref.path)) {
const index = rowsDbMap.get(row._rowy_ref.path)!;
const merged = updateRowData({ ...rowsDb[index] }, row);
rowsDb.splice(index, 1);
rowsDbMap.delete(row._rowy_ref.path);
return merged;
}
return row;
});
// Merge the two arrays
return [...rowsLocalToMerge, ...rowsDb];
return [
...rowsLocalToMerge,
...rowsDb.filter((row) => rowsDbMap.has(row._rowy_ref.path)),
];
});
/** Store next page state for infinite scroll */

View File

@@ -109,7 +109,7 @@ export type ImportAirtableData = { records: Record<string, any>[] };
/** Store import CSV popover and wizard state */
export const importCsvAtom = atom<{
importType: "csv" | "tsv";
importType: "csv" | "tsv" | "json";
csvData: ImportCsvData | null;
}>({ importType: "csv", csvData: null });
@@ -142,14 +142,26 @@ export const selectedCellAtom = atom<SelectedCell | null>(null);
export const contextMenuTargetAtom = atom<HTMLElement | null>(null);
export type CloudLogFilters = {
type: "webhook" | "functions" | "audit" | "build";
type: "extension" | "webhook" | "column" | "audit" | "build" | "functions";
timeRange:
| { type: "seconds" | "minutes" | "hours" | "days"; value: number }
| { type: "range"; start: Date; end: Date };
severity?: Array<keyof typeof SEVERITY_LEVELS>;
webhook?: string[];
extension?: string[];
column?: string[];
auditRowId?: string;
buildLogExpanded?: number;
functionType?: (
| "connector"
| "derivative-script"
| "action"
| "derivative-function"
| "extension"
| "defaultValue"
| "hooks"
)[];
loggingSource?: ("backend-scripts" | "backend-function" | "hooks")[];
};
/** Store cloud log modal filters in URL */
export const cloudLogFiltersAtom = atomWithHash<CloudLogFilters>(

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import Editor, { EditorProps } from "@monaco-editor/react";
import Editor, { EditorProps, Monaco } from "@monaco-editor/react";
import type { editor } from "monaco-editor/esm/vs/editor/editor.api";
import { useTheme, Box, BoxProps, AppBar, Toolbar } from "@mui/material";
@@ -72,6 +72,36 @@ export default function CodeEditor({
onValidate?.(markers);
};
const validate = (monaco: Monaco, model: editor.ITextModel) => {
const markers = [];
for (let i = 1; i < model.getLineCount() + 1; i++) {
const range = {
startLineNumber: i,
startColumn: 1,
endLineNumber: i,
endColumn: model.getLineLength(i) + 1,
};
const line = model.getValueInRange(range);
for (const keyword of ["console.log", "console.warn", "console.error"]) {
const consoleLogIndex = line.indexOf(keyword);
if (consoleLogIndex >= 0) {
markers.push({
message: `Replace with ${keyword.replace(
"console",
"logging"
)}: Rowy Cloud Logging provides a better experience to view logs. Simply replace 'console' with 'logging'. \n\nhttps://docs.rowy.io/cloud-logs`,
severity: monaco.MarkerSeverity.Warning,
startLineNumber: range.startLineNumber,
endLineNumber: range.endLineNumber,
startColumn: consoleLogIndex + 1,
endColumn: consoleLogIndex + keyword.length + 1,
});
}
}
}
monaco.editor.setModelMarkers(model, "owner", markers);
};
return (
<TrapFocus open={fullScreen}>
<Box
@@ -94,6 +124,12 @@ export default function CodeEditor({
beforeMount={(monaco) => {
monaco.editor.defineTheme("github-light", githubLightTheme as any);
monaco.editor.defineTheme("github-dark", githubDarkTheme as any);
monaco.editor.onDidCreateModel((model) => {
validate(monaco, model);
model.onDidChangeContent(() => {
validate(monaco, model);
});
});
}}
onMount={(editor) => {
if (onFocus) editor.onDidFocusEditorWidget(onFocus);

View File

@@ -9,6 +9,9 @@ import { projectScope, projectIdAtom } from "@src/atoms/projectScope";
export interface ICodeEditorHelperProps {
docLink: string;
disableDefaultVariables?: boolean;
disableSecretManagerLink?: boolean;
disableCloudManagerLink?: boolean;
additionalVariables?: {
key: string;
description: string;
@@ -17,28 +20,37 @@ export interface ICodeEditorHelperProps {
export default function CodeEditorHelper({
docLink,
disableDefaultVariables,
disableSecretManagerLink,
disableCloudManagerLink,
additionalVariables,
}: ICodeEditorHelperProps) {
const [projectId] = useAtom(projectIdAtom, projectScope);
const availableVariables = [
{
key: "db",
description: `db object provides access to firestore database instance of this project. giving you access to any collection or document in this firestore instance`,
},
{
key: "auth",
description: `auth provides access to a firebase auth instance, can be used to manage auth users or generate tokens.`,
},
{
key: "storage",
description: `firebase Storage can be accessed through this, storage.bucket() returns default storage bucket of the firebase project.`,
},
{
key: "rowy",
description: `rowy provides a set of functions that are commonly used, such as easy file uploads & access to GCP Secret Manager`,
},
];
const availableVariables = disableDefaultVariables
? []
: [
{
key: "db",
description: `db object provides access to firestore database instance of this project. giving you access to any collection or document in this firestore instance`,
},
{
key: "auth",
description: `auth provides access to a firebase auth instance, can be used to manage auth users or generate tokens.`,
},
{
key: "storage",
description: `firebase Storage can be accessed through this, storage.bucket() returns default storage bucket of the firebase project.`,
},
{
key: "rowy",
description: `rowy provides a set of functions that are commonly used, such as easy file uploads & access to GCP Secret Manager`,
},
{
key: "logging",
description: `logging.log is encouraged to replace console.log`,
},
];
return (
<Stack
@@ -73,29 +85,33 @@ export default function CodeEditorHelper({
spacing={1}
style={{ marginTop: -4 }}
>
<Tooltip title="Secret Manager&nbsp;↗">
<IconButton
size="small"
color="primary"
target="_blank"
rel="noopener noreferrer"
href={`https://console.cloud.google.com/security/secret-manager?project=${projectId}`}
>
<SecretsIcon fontSize="small" />
</IconButton>
</Tooltip>
{!disableSecretManagerLink && (
<Tooltip title="Secret Manager&nbsp;↗">
<IconButton
size="small"
color="primary"
target="_blank"
rel="noopener noreferrer"
href={`https://console.cloud.google.com/security/secret-manager?project=${projectId}`}
>
<SecretsIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Configure Cloud Function&nbsp;↗">
<IconButton
size="small"
color="primary"
target="_blank"
rel="noopener noreferrer"
href={`https://console.cloud.google.com/functions/list?project=${projectId}`}
>
<FunctionsIcon fontSize="small" />
</IconButton>
</Tooltip>
{!disableCloudManagerLink && (
<Tooltip title="Configure Cloud Function&nbsp;↗">
<IconButton
size="small"
color="primary"
target="_blank"
rel="noopener noreferrer"
href={`https://console.cloud.google.com/functions/list?project=${projectId}`}
>
<FunctionsIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Examples & documentation&nbsp;↗">
<IconButton

View File

@@ -26,6 +26,7 @@ type ExtensionContext = {
extensionBody: any;
};
RULES_UTILS: any;
logging: RowyLogging;
};
// extension body definition

View File

@@ -17,6 +17,11 @@ type uploadOptions = {
folderPath?: string;
fileName?: string;
};
type RowyLogging = {
log: (...payload: any[]) => void;
warn: (...payload: any[]) => void;
error: (...payload: any[]) => void;
};
interface Rowy {
metadata: {
/**

View File

@@ -1,9 +1,4 @@
import { useEffect } from "react";
// import {
// quicktype,
// InputData,
// jsonInputForTargetLanguage,
// } from "quicktype-core";
import { useAtom } from "jotai";
import {
@@ -13,15 +8,10 @@ import {
} from "@src/atoms/tableScope";
import { useMonaco } from "@monaco-editor/react";
import type { languages } from "monaco-editor/esm/vs/editor/editor.api";
import githubLightTheme from "./github-light-default.json";
import githubDarkTheme from "./github-dark-default.json";
import { useTheme } from "@mui/material";
import type { SystemStyleObject, Theme } from "@mui/system";
// TODO:
// import { getFieldType, getFieldProp } from "@src/components/fields";
/* eslint-disable import/no-webpack-loader-syntax */
import firestoreDefs from "!!raw-loader!./firestore.d.ts";
import firebaseAuthDefs from "!!raw-loader!./firebaseAuth.d.ts";
@@ -72,7 +62,6 @@ export default function useMonacoCustomizations({
};
}, []);
// Initialize external libs & TypeScript compiler options
useEffect(() => {
if (!monaco) return;
@@ -95,6 +84,8 @@ export default function useMonacoCustomizations({
"ts:filename/utils.d.ts"
);
monaco.languages.typescript.javascriptDefaults.addExtraLib(rowyUtilsDefs);
setLoggingReplacementActions();
} catch (error) {
console.error(
"An error occurred during initialization of Monaco: ",
@@ -135,6 +126,52 @@ export default function useMonacoCustomizations({
}
}, [monaco, stringifiedDiagnosticsOptions]);
const setLoggingReplacementActions = () => {
if (!monaco) return;
const { dispose } = monaco.languages.registerCodeActionProvider(
"javascript",
{
provideCodeActions: (model, range, context, token) => {
const actions = context.markers
.filter((error) => {
return error.message.includes("Rowy Cloud Logging");
})
.map((error) => {
// first sentence of the message is "Replace with logging.[log/warn/error]"
const firstSentence = error.message.split(":")[0];
const replacement = firstSentence.split("with ")[1];
return {
title: firstSentence,
diagnostics: [error],
kind: "quickfix",
edit: {
edits: [
{
resource: model.uri,
edit: {
range: error,
text: replacement,
},
},
],
},
isPreferred: true,
};
});
return {
actions: actions,
dispose: () => {},
};
},
}
);
monaco.editor.onWillDisposeModel((model) => {
// dispose code action provider when model is disposed
// this makes sure code actions are not displayed multiple times
dispose();
});
};
const addJsonFieldDefinition = async (
columnKey: string,
interfaceName: string

View File

@@ -20,11 +20,11 @@ import {
ColumnPlusBefore as ColumnPlusBeforeIcon,
ColumnPlusAfter as ColumnPlusAfterIcon,
ColumnRemove as ColumnRemoveIcon,
CloudLogs as LogsIcon,
} from "@src/assets/icons";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import EditIcon from "@mui/icons-material/EditOutlined";
// import ReorderIcon from "@mui/icons-material/Reorder";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import EvalIcon from "@mui/icons-material/PlayCircleOutline";
@@ -51,6 +51,8 @@ import {
tableFiltersPopoverAtom,
tableNextPageAtom,
tableSchemaAtom,
cloudLogFiltersAtom,
tableModalAtom,
} from "@src/atoms/tableScope";
import { FieldType } from "@src/constants/fields";
import { getFieldProp } from "@src/components/fields";
@@ -62,6 +64,7 @@ import {
} from "@src/utils/table";
import { runRoutes } from "@src/constants/runRoutes";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import useSaveTableSorts from "@src/components/Table/ColumnHeader/useSaveTableSorts";
export interface IMenuModalProps {
name: string;
@@ -107,11 +110,15 @@ export default function ColumnMenu({
);
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const setModal = useSetAtom(tableModalAtom, tableScope);
const setCloudLogFilters = useSetAtom(cloudLogFiltersAtom, tableScope);
const snackLogContext = useSnackLogContext();
const [altPress] = useAtom(altPressAtom, projectScope);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const triggerSaveTableSorts = useSaveTableSorts(canEditColumns);
if (!columnMenu) return null;
const { column, anchorEl } = columnMenu;
if (column.type === FieldType.last) return null;
@@ -185,6 +192,9 @@ export default function ColumnMenu({
setTableSorts(
isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }]
);
if (!isSorted || isAsc) {
triggerSaveTableSorts([{ key: sortKey, direction: "desc" }]);
}
handleClose();
},
active: isSorted && !isAsc,
@@ -199,6 +209,9 @@ export default function ColumnMenu({
setTableSorts(
isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }]
);
if (!isSorted || !isAsc) {
triggerSaveTableSorts([{ key: sortKey, direction: "asc" }]);
}
handleClose();
},
active: isSorted && isAsc,
@@ -230,14 +243,24 @@ export default function ColumnMenu({
defaultQuery: {
key: column.fieldName,
operator:
getFieldProp("filter", column.type)!.operators[0]?.value || "==",
getFieldProp(
"filter",
column.type === FieldType.derivative
? column.config?.renderFieldType
: column.type
)!.operators[0]?.value || "==",
value: "",
},
});
handleClose();
},
active: column.hidden,
disabled: !getFieldProp("filter", column.type),
disabled: !getFieldProp(
"filter",
column.type === FieldType.derivative
? column.config?.renderFieldType
: column.type
),
},
];
@@ -314,26 +337,29 @@ export default function ColumnMenu({
},
disabled: !isConfigurable,
},
// {
// label: "Re-order",
// icon: <ReorderIcon />,
// onClick: () => alert("REORDER"),
// },
// {
// label: "Hide for everyone",
// activeLabel: "Show",
// icon: <VisibilityOffIcon />,
// activeIcon: <VisibilityIcon />,
// onClick: () => {
// actions.update(column.key, { hidden: !column.hidden });
// handleClose();
// },
// active: column.hidden,
// color: "error" as "error",
// },
];
if (
column?.config?.defaultValue?.type === "dynamic" ||
[FieldType.action, FieldType.derivative, FieldType.connector].includes(
column.type
)
) {
configActions.push({
key: "logs",
label: altPress ? "Logs" : "Logs…",
icon: <LogsIcon />,
onClick: () => {
setModal("cloudLogs");
setCloudLogFilters({
type: "column",
timeRange: { type: "days", value: 7 },
column: [column.key],
});
},
});
}
// TODO: Generalize
const handleEvaluateAll = async () => {
try {

View File

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

View File

@@ -1,5 +1,5 @@
import { lazy, Suspense, createElement, useState } from "react";
import { useAtom } from "jotai";
import { useAtom, useSetAtom } from "jotai";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
@@ -17,6 +17,7 @@ import {
projectScope,
compatibleRowyRunVersionAtom,
projectSettingsAtom,
rowyRunModalAtom,
} from "@src/atoms/projectScope";
import { ColumnConfig } from "@src/types/table";
@@ -52,18 +53,23 @@ function CodeEditor({ type, column, handleChange }: ICodeEditorProps) {
} else if (column.config?.defaultValue?.dynamicValueFn) {
dynamicValueFn = column.config?.defaultValue?.dynamicValueFn;
} else if (column.config?.defaultValue?.script) {
dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth})=>{
${column.config?.defaultValue.script}
}`;
dynamicValueFn = `const dynamicValueFn: DefaultValue = 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("dynamicValueFn started")
${column.config?.defaultValue.script}
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`;
} else {
dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth})=>{
// Write your default value code here
// for example:
// generate random hex color
// const color = "#" + Math.floor(Math.random() * 16777215).toString(16);
// return color;
// checkout the documentation for more info: https://docs.rowy.io/how-to/default-values#dynamic
}`;
dynamicValueFn = `const dynamicValueFn: DefaultValue = 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("dynamicValueFn started")
// Example: generate random hex color
// const color = "#" + Math.floor(Math.random() * 16777215).toString(16);
// return color;
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`;
}
return (
@@ -93,6 +99,7 @@ export default function DefaultValueInput({
column,
}: IDefaultValueInputProps) {
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
const openRowyRunModal = useSetAtom(rowyRunModalAtom, projectScope);
const _type =
column.type !== FieldType.derivative
@@ -152,9 +159,23 @@ export default function DefaultValueInput({
"Dynamic"
) : (
<>
Dynamic {" "}
Dynamic{" "}
<Typography color="error" variant="inherit" component="span">
Requires Rowy Run setup
Requires
<span
style={{
marginLeft: "3px",
cursor: "pointer",
pointerEvents: "all",
textDecoration: "underline",
}}
onClick={(e) => {
e.stopPropagation();
openRowyRunModal({ feature: "Dynamic Default Value" });
}}
>
Cloud Function
</span>
</Typography>
</>
)

View File

@@ -4,5 +4,6 @@ type DefaultValueContext = {
storage: firebasestorage.Storage;
db: FirebaseFirestore.Firestore;
auth: firebaseauth.BaseAuth;
logging: RowyLogging;
};
type DefaultValue = (context: DefaultValueContext) => "PLACEHOLDER_OUTPUT_TYPE";

View File

@@ -1,16 +1,24 @@
import MultiSelect from "@rowy/multiselect";
import { ListItemIcon } from "@mui/material";
import { Box, ListItemIcon, Typography } from "@mui/material";
import { FIELDS } from "@src/components/fields";
import { FieldType } from "@src/constants/fields";
import { getFieldProp } from "@src/components/fields";
import { useSetAtom, useAtom } from "jotai";
import {
projectScope,
projectSettingsAtom,
rowyRunModalAtom,
} from "@src/atoms/projectScope";
export interface IFieldsDropdownProps {
value: FieldType | "";
onChange: (value: FieldType) => void;
hideLabel?: boolean;
label?: string;
options?: FieldType[];
[key: string]: any;
}
@@ -25,13 +33,21 @@ export default function FieldsDropdown({
options: optionsProp,
...props
}: IFieldsDropdownProps) {
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
const openRowyRunModal = useSetAtom(rowyRunModalAtom, projectScope);
const fieldTypesToDisplay = optionsProp
? FIELDS.filter((fieldConfig) => optionsProp.indexOf(fieldConfig.type) > -1)
: FIELDS;
const options = fieldTypesToDisplay.map((fieldConfig) => ({
label: fieldConfig.name,
value: fieldConfig.type,
}));
const options = fieldTypesToDisplay.map((fieldConfig) => {
const requireCloudFunctionSetup =
fieldConfig.requireCloudFunction && !projectSettings.rowyRunUrl;
return {
label: fieldConfig.name,
value: fieldConfig.type,
disabled: requireCloudFunctionSetup,
requireCloudFunctionSetup,
};
});
return (
<MultiSelect
@@ -44,6 +60,20 @@ export default function FieldsDropdown({
AutocompleteProps: {
groupBy: (option: typeof options[number]) =>
getFieldProp("group", option.value),
ListboxProps: {
sx: {
'& li.MuiAutocomplete-option[aria-disabled="true"]': {
opacity: 1,
},
'& li.MuiAutocomplete-option[aria-disabled="true"] > *': {
opacity: 0.4,
},
'& li.MuiAutocomplete-option[aria-disabled="true"] > .require-cloud-function':
{
opacity: 1,
},
},
},
},
} as any)}
itemRenderer={(option) => (
@@ -51,7 +81,33 @@ export default function FieldsDropdown({
<ListItemIcon style={{ minWidth: 40 }}>
{getFieldProp("icon", option.value as FieldType)}
</ListItemIcon>
{option.label}
<Typography>{option.label}</Typography>
{option.requireCloudFunctionSetup && (
<Typography
color="error"
variant="inherit"
component="span"
marginLeft={1}
className={"require-cloud-function"}
>
{" "}
Requires
<span
style={{
marginLeft: "3px",
cursor: "pointer",
pointerEvents: "all",
textDecoration: "underline",
}}
onClick={(e) => {
e.stopPropagation();
openRowyRunModal({ feature: option.label });
}}
>
Cloud Function
</span>
</Typography>
)}
</>
)}
label={label || "Field type"}

View File

@@ -92,7 +92,17 @@ export default function SteppedAccordion({
}
{...labelButtonProps}
>
<StepLabel error={error} {...labelProps}>
<StepLabel
error={error}
{...labelProps}
StepIconProps={{
sx: {
"&.Mui-active": {
transform: "rotate(0deg) !important",
},
},
}}
>
{title}
{content && <ExpandIcon sx={{ mr: -0.5 }} />}
</StepLabel>

View File

@@ -1,6 +1,6 @@
import { useAtom } from "jotai";
import { useParams, Link as RouterLink } from "react-router-dom";
import { find, camelCase, uniq } from "lodash-es";
import { find, camelCase } from "lodash-es";
import {
Stack,
@@ -12,13 +12,9 @@ import {
} from "@mui/material";
import ReadOnlyIcon from "@mui/icons-material/EditOffOutlined";
import InfoTooltip from "@src/components/InfoTooltip";
import RenderedMarkdown from "@src/components/RenderedMarkdown";
import {
projectScope,
userRolesAtom,
tableDescriptionDismissedAtom,
tablesAtom,
} from "@src/atoms/projectScope";
import { ROUTES } from "@src/constants/routes";
@@ -31,10 +27,6 @@ export default function BreadcrumbsTableRoot(props: StackProps) {
const { id } = useParams();
const [userRoles] = useAtom(userRolesAtom, projectScope);
const [dismissed, setDismissed] = useAtom(
tableDescriptionDismissedAtom,
projectScope
);
const [tables] = useAtom(tablesAtom, projectScope);
const tableSettings = find(tables, ["id", id]);
@@ -83,28 +75,6 @@ export default function BreadcrumbsTableRoot(props: StackProps) {
<ReadOnlyIcon fontSize="small" sx={{ ml: 0.5 }} color="action" />
</Tooltip>
)}
{tableSettings.description && (
<InfoTooltip
description={
<div>
<RenderedMarkdown
children={tableSettings.description}
restrictionPreset="singleLine"
/>
</div>
}
buttonLabel="Table info"
tooltipProps={{
componentsProps: {
popper: { sx: { zIndex: "appBar" } },
tooltip: { sx: { maxWidth: "75vw" } },
} as any,
}}
defaultOpen={!dismissed.includes(tableSettings.id)}
onClose={() => setDismissed((d) => uniq([...d, tableSettings.id]))}
/>
)}
</Stack>
);
}

View File

@@ -233,6 +233,7 @@ export const ColumnHeader = memo(function ColumnHeader({
sortKey={sortKey}
currentSort={currentSort}
tabIndex={focusInsideCell ? 0 : -1}
canEditColumns={canEditColumns}
/>
)}

View File

@@ -9,6 +9,7 @@ import IconSlash, {
} from "@src/components/IconSlash";
import { tableScope, tableSortsAtom } from "@src/atoms/tableScope";
import useSaveTableSorts from "./useSaveTableSorts";
export const SORT_STATES = ["none", "desc", "asc"] as const;
@@ -16,6 +17,7 @@ export interface IColumnHeaderSortProps {
sortKey: string;
currentSort: typeof SORT_STATES[number];
tabIndex?: number;
canEditColumns: boolean;
}
/**
@@ -26,15 +28,24 @@ export const ColumnHeaderSort = memo(function ColumnHeaderSort({
sortKey,
currentSort,
tabIndex,
canEditColumns,
}: IColumnHeaderSortProps) {
const setTableSorts = useSetAtom(tableSortsAtom, tableScope);
const nextSort =
SORT_STATES[SORT_STATES.indexOf(currentSort) + 1] ?? SORT_STATES[0];
const triggerSaveTableSorts = useSaveTableSorts(canEditColumns);
const handleSortClick = () => {
if (nextSort === "none") setTableSorts([]);
else setTableSorts([{ key: sortKey, direction: nextSort }]);
triggerSaveTableSorts([
{
key: sortKey,
direction: nextSort === "none" ? "asc" : nextSort,
},
]);
};
return (

View File

@@ -0,0 +1,98 @@
import { useCallback, useState } from "react";
import { useAtom } from "jotai";
import { SnackbarKey, useSnackbar } from "notistack";
import LoadingButton from "@mui/lab/LoadingButton";
import CheckIcon from "@mui/icons-material/Check";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import {
tableIdAtom,
tableScope,
updateTableSchemaAtom,
} from "@src/atoms/tableScope";
import { projectScope, updateUserSettingsAtom } from "@src/atoms/projectScope";
import { TableSort } from "@src/types/table";
function useSaveTableSorts(canEditColumns: boolean) {
const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope);
const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope);
const [tableId] = useAtom(tableIdAtom, tableScope);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const [snackbarId, setSnackbarId] = useState<SnackbarKey | null>(null);
// Offer to save when table sorts changes
const trigger = useCallback(
(sorts: TableSort[]) => {
if (!updateTableSchema) throw new Error("Cannot update table schema");
if (updateUserSettings) {
updateUserSettings({
tables: {
[`${tableId}`]: { sorts },
},
});
}
if (!canEditColumns) return;
if (snackbarId) {
closeSnackbar(snackbarId);
}
setSnackbarId(
enqueueSnackbar("Apply this sorting for all users?", {
action: (
<SaveTableSortButton
updateTable={async () => await updateTableSchema({ sorts })}
/>
),
anchorOrigin: { horizontal: "center", vertical: "top" },
})
);
return () => (snackbarId ? closeSnackbar(snackbarId) : null);
},
[
updateUserSettings,
canEditColumns,
snackbarId,
enqueueSnackbar,
tableId,
closeSnackbar,
updateTableSchema,
]
);
return trigger;
}
function SaveTableSortButton({ updateTable }: { updateTable: Function }) {
const [state, setState] = useState<"" | "loading" | "success" | "error">("");
const handleSaveToSchema = async () => {
setState("loading");
try {
await updateTable();
setState("success");
} catch (e) {
setState("error");
}
};
return (
<LoadingButton
variant="contained"
color="primary"
onClick={handleSaveToSchema}
loading={Boolean(state)}
loadingIndicator={
state === "success" ? (
<CheckIcon color="primary" />
) : (
<CircularProgressOptical size={20} color="primary" />
)
}
>
Save
</LoadingButton>
);
}
export default useSaveTableSorts;

View File

@@ -1,94 +1,19 @@
import { useAtom, useSetAtom } from "jotai";
import { useSnackbar } from "notistack";
import { get, find } from "lodash-es";
// import Cut from "@mui/icons-material/ContentCut";
import { Copy as CopyCells } from "@src/assets/icons";
// import Cut from "@mui/icons-material/ContentCut";
import Paste from "@mui/icons-material/ContentPaste";
import {
tableScope,
tableSchemaAtom,
tableRowsAtom,
updateFieldAtom,
} from "@src/atoms/tableScope";
import { getFieldProp, getFieldType } from "@src/components/fields";
import { IFieldConfig } from "@src/components/fields/types";
import { useMenuAction } from "@src/components/Table/useMenuAction";
// TODO: Remove this and add `handlePaste` function to column config
export const BasicContextMenuActions: IFieldConfig["contextMenuActions"] = (
selectedCell,
reset
) => {
const { enqueueSnackbar } = useSnackbar();
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableRows] = useAtom(tableRowsAtom, tableScope);
const updateField = useSetAtom(updateFieldAtom, tableScope);
const selectedCol = tableSchema.columns?.[selectedCell.columnKey];
if (!selectedCol) return [];
const selectedRow = find(tableRows, ["_rowy_ref.path", selectedCell.path]);
const cellValue = get(selectedRow, selectedCol.fieldName);
const handleClose = async () => await reset?.();
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(cellValue);
enqueueSnackbar("Copied");
} catch (error) {
enqueueSnackbar(`Failed to copy:${error}`, { variant: "error" });
}
handleClose();
};
// const handleCut = async () => {
// try {
// await navigator.clipboard.writeText(cellValue);
// if (typeof cellValue !== "undefined")
// updateField({
// path: selectedCell.path,
// fieldName: selectedCol.fieldName,
// value: undefined,
// deleteField: true,
// });
// } catch (error) {
// enqueueSnackbar(`Failed to cut: ${error}`, { variant: "error" });
// }
// handleClose();
// };
const handlePaste = async () => {
try {
if (!selectedCol) return;
const text = await navigator.clipboard.readText();
const cellDataType = getFieldProp("dataType", getFieldType(selectedCol));
let parsed;
switch (cellDataType) {
case "number":
parsed = Number(text);
if (isNaN(parsed)) throw new Error(`${text} is not a number`);
break;
case "string":
parsed = text;
break;
default:
parsed = JSON.parse(text);
break;
}
updateField({
path: selectedCell.path,
fieldName: selectedCol.fieldName,
value: parsed,
});
} catch (error) {
enqueueSnackbar(`Failed to paste: ${error}`, { variant: "error" });
}
handleClose();
};
const { handleCopy, handlePaste, cellValue } = useMenuAction(
selectedCell,
handleClose
);
const contextMenuActions = [
// { label: "Cut", icon: <Cut />, onClick: handleCut },

View File

@@ -192,7 +192,12 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
value: null,
deleteField: true,
});
const columnFilters = getFieldProp("filter", selectedColumn?.type);
const columnFilters = getFieldProp(
"filter",
selectedColumn?.type === FieldType.derivative
? selectedColumn.config?.renderFieldType
: selectedColumn?.type
);
const handleFilterValue = () => {
openTableFiltersPopover({
defaultQuery: {

View File

@@ -1,9 +1,8 @@
import { useAtom, useSetAtom } from "jotai";
import { useSetAtom } from "jotai";
import { Box, BoxProps, Button } from "@mui/material";
import { AddColumn as AddColumnIcon } from "@src/assets/icons";
import { projectScope, userRolesAtom } from "@src/atoms/projectScope";
import { tableScope, columnModalAtom } from "@src/atoms/tableScope";
import { spreadSx } from "@src/utils/ui";
@@ -17,10 +16,43 @@ export default function FinalColumnHeader({
canAddColumns,
...props
}: IFinalColumnHeaderProps) {
const [userRoles] = useAtom(userRolesAtom, projectScope);
const openColumnModal = useSetAtom(columnModalAtom, tableScope);
if (!userRoles.includes("ADMIN"))
if (canAddColumns)
return (
<Box
role="columnheader"
{...props}
sx={[
{
backgroundColor: "background.default",
border: (theme) => `1px solid ${theme.palette.divider}`,
borderLeft: "none",
borderTopRightRadius: (theme) => theme.shape.borderRadius,
borderBottomRightRadius: (theme) => theme.shape.borderRadius,
display: "flex",
alignItems: "center",
width: 32 * 3 + 4 * 2 + 10 * 2,
overflow: "visible",
px: 0.75,
},
...spreadSx(props.sx),
]}
className="column-header"
>
<Button
onClick={() => openColumnModal({ type: "new" })}
variant="contained"
color="primary"
startIcon={<AddColumnIcon />}
style={{ zIndex: 1, flexShrink: 0 }}
tabIndex={focusInsideCell ? 0 : -1}
>
Add column
</Button>
</Box>
);
else
return (
<Box
role="columnheader"
@@ -47,40 +79,4 @@ export default function FinalColumnHeader({
Actions
</Box>
);
return (
<Box
role="columnheader"
{...props}
sx={[
{
backgroundColor: "background.default",
border: (theme) => `1px solid ${theme.palette.divider}`,
borderLeft: "none",
borderTopRightRadius: (theme) => theme.shape.borderRadius,
borderBottomRightRadius: (theme) => theme.shape.borderRadius,
display: "flex",
alignItems: "center",
width: 32 * 3 + 4 * 2 + 10 * 2,
overflow: "visible",
px: 0.75,
},
...spreadSx(props.sx),
]}
className="column-header"
>
<Button
onClick={() => openColumnModal({ type: "new" })}
variant="contained"
color="primary"
startIcon={<AddColumnIcon />}
style={{ zIndex: 1, flexShrink: 0 }}
tabIndex={focusInsideCell ? 0 : -1}
>
Add column
</Button>
</Box>
);
}

View File

@@ -19,6 +19,17 @@ export const StyledCell = styled("div")(({ theme }) => ({
alignItems: "center",
},
"& > .cell-contents-contain-none": {
padding: "0 var(--cell-padding)",
width: "100%",
height: "100%",
contain: "none",
overflow: "hidden",
display: "flex",
alignItems: "center",
},
backgroundColor: "var(--cell-background-color)",
border: `1px solid ${theme.palette.divider}`,

View File

@@ -1,5 +1,5 @@
import { useMemo, useRef, useState, useEffect, useCallback } from "react";
import useStateRef from "react-usestateref";
// import useStateRef from "react-usestateref"; // testing with useStateWithRef
import { useAtom, useSetAtom } from "jotai";
import { useThrottledCallback } from "use-debounce";
import {
@@ -30,11 +30,18 @@ import {
tableNextPageAtom,
tablePageAtom,
updateColumnAtom,
selectedCellAtom,
tableSortsAtom,
tableIdAtom,
} from "@src/atoms/tableScope";
import { projectScope, userSettingsAtom } from "@src/atoms/projectScope";
import { getFieldType, getFieldProp } from "@src/components/fields";
import { useKeyboardNavigation } from "./useKeyboardNavigation";
import { useMenuAction } from "./useMenuAction";
import { useSaveColumnSizing } from "./useSaveColumnSizing";
import useHotKeys from "./useHotKey";
import type { TableRow, ColumnConfig } from "@src/types/table";
import useStateWithRef from "./useStateWithRef"; // testing with useStateWithRef
export const DEFAULT_ROW_HEIGHT = 41;
export const DEFAULT_COL_WIDTH = 150;
@@ -95,11 +102,18 @@ export default function Table({
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
// Get user settings and tableId for applying sort sorting
const [userSettings] = useAtom(userSettingsAtom, projectScope);
const [tableId] = useAtom(tableIdAtom, tableScope);
const setTableSorts = useSetAtom(tableSortsAtom, tableScope);
// Store a **state** and reference to the container element
// so the state can re-render `TableBody`, preventing virtualization
// not detecting scroll if the container element was initially `null`
const [containerEl, setContainerEl, containerRef] =
useStateRef<HTMLDivElement | null>(null);
// useStateRef<HTMLDivElement | null>(null); // <-- older approach with useStateRef
useStateWithRef<HTMLDivElement | null>(null); // <-- newer approach with custom hook
const gridRef = useRef<HTMLDivElement>(null);
// Get column defs from table schema
@@ -181,6 +195,13 @@ export default function Table({
tableRows,
leafColumns,
});
const [selectedCell] = useAtom(selectedCellAtom, tableScope);
const { handleCopy, handlePaste, handleCut } = useMenuAction(selectedCell);
const { handler: hotKeysHandler } = useHotKeys([
["mod+C", handleCopy],
["mod+X", handleCut],
["mod+V", handlePaste],
]);
// Handle prompt to save local column sizes if user `canEditColumns`
useSaveColumnSizing(columnSizing, canEditColumns);
@@ -223,9 +244,24 @@ export default function Table({
containerRef,
]);
// apply user default sort on first render
const [applySort, setApplySort] = useState(true);
useEffect(() => {
if (applySort && Object.keys(tableSchema).length) {
const userDefaultSort = userSettings.tables?.[tableId]?.sorts || [];
setTableSorts(
userDefaultSort.length ? userDefaultSort : tableSchema.sorts || []
);
setApplySort(false);
}
}, [tableSchema, userSettings, tableId, setTableSorts, applySort]);
return (
<div
ref={(el) => setContainerEl(el)}
ref={(el) => {
if (!el) return;
setContainerEl(el);
}}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
style={{ overflow: "auto", width: "100%", height: "100%" }}
>
@@ -242,7 +278,10 @@ export default function Table({
"--row-height": `${tableSchema.rowHeight || DEFAULT_ROW_HEIGHT}px`,
} as any
}
onKeyDown={handleKeyDown}
onKeyDown={(e) => {
handleKeyDown(e);
hotKeysHandler(e);
}}
>
<div
className="thead"

View File

@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect } from "react";
import useStateRef from "react-usestateref";
import { useSetAtom } from "jotai";
import { isEqual } from "lodash-es";
import { useSnackbar } from "notistack";
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
import type {
@@ -45,6 +46,8 @@ export default function EditorCellController({
const [isDirty, setIsDirty, isDirtyRef] = useStateRef(false);
const updateField = useSetAtom(updateFieldAtom, tableScope);
const { enqueueSnackbar } = useSnackbar();
// When this cells data has updated, update the local value if
// its not dirty and the value is different
useEffect(() => {
@@ -53,17 +56,20 @@ export default function EditorCellController({
}, [isDirty, localValueRef, setLocalValue, value]);
// This is where we update the documents
const handleSubmit = () => {
const handleSubmit = async () => {
// props.disabled should always be false as withRenderTableCell would
// render DisplayCell instead of EditorCell
if (props.disabled || !isDirtyRef.current) return;
updateField({
path: props._rowy_ref.path,
fieldName: props.column.fieldName,
value: localValueRef.current,
deleteField: localValueRef.current === undefined,
});
try {
await updateField({
path: props._rowy_ref.path,
fieldName: props.column.fieldName,
value: localValueRef.current,
deleteField: localValueRef.current === undefined,
});
} catch (e) {
enqueueSnackbar((e as Error).message, { variant: "error" });
}
};
useLayoutEffect(() => {

View File

@@ -164,6 +164,7 @@ export default function withRenderTableCell(
<DisplayCellComponent {...basicCellProps} />
</div>
);
if (disabled || (editorMode !== "inline" && !focusInsideCell))
return displayCell;
@@ -191,7 +192,7 @@ export default function withRenderTableCell(
if (editorMode === "inline") {
return (
<div
className="cell-contents"
className="cell-contents-contain-none"
style={options.disablePadding ? { padding: 0 } : undefined}
ref={displayCellRef}
>

View File

@@ -0,0 +1,99 @@
import { useCallback } from "react";
type HotKeysAction = [
string,
(event: React.KeyboardEvent<HTMLElement> | KeyboardEvent) => void
];
export default function useHotKeys(actions: HotKeysAction[]) {
// master event handler
const handler = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const event_ = "nativeEvent" in event ? event.nativeEvent : event;
actions.forEach(([hotkey, handler_]) => {
if (getHotkeyMatcher(hotkey)(event_)) {
event.preventDefault();
handler_(event_);
}
});
},
[actions]
);
return { handler } as const;
}
type KeyboardModifiers = {
alt: boolean;
ctrl: boolean;
meta: boolean;
mod: boolean;
shift: boolean;
};
export type Hotkey = KeyboardModifiers & {
key?: string;
};
function isExactHotkey(hotkey: Hotkey, event: KeyboardEvent): boolean {
const { alt, ctrl, meta, mod, shift, key } = hotkey;
const { altKey, ctrlKey, metaKey, shiftKey, key: pressedKey } = event;
if (alt !== altKey) {
return false;
}
if (mod) {
if (!ctrlKey && !metaKey) {
return false;
}
} else {
if (ctrl !== ctrlKey) {
return false;
}
if (meta !== metaKey) {
return false;
}
}
if (shift !== shiftKey) {
return false;
}
if (
key &&
(pressedKey.toLowerCase() === key.toLowerCase() ||
event.code.replace("Key", "").toLowerCase() === key.toLowerCase())
) {
return true;
}
return false;
}
type CheckHotkeyMatch = (event: KeyboardEvent) => boolean;
export function getHotkeyMatcher(hotkey: string): CheckHotkeyMatch {
return (event) => isExactHotkey(parseHotkey(hotkey), event);
}
function parseHotkey(hotkey: string): Hotkey {
const keys = hotkey
.toLowerCase()
.split("+")
.map((part) => part.trim());
const modifiers: KeyboardModifiers = {
alt: keys.includes("alt"),
ctrl: keys.includes("ctrl"),
meta: keys.includes("meta"),
mod: keys.includes("mod"),
shift: keys.includes("shift"),
};
const reservedKeys = ["alt", "ctrl", "meta", "shift", "mod"];
const freeKey = keys.find((key) => !reservedKeys.includes(key));
return {
...modifiers,
key: freeKey,
};
}

View File

@@ -0,0 +1,161 @@
import { useCallback, useState, useEffect } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useSnackbar } from "notistack";
import { get, find } from "lodash-es";
import {
tableScope,
tableSchemaAtom,
tableRowsAtom,
updateFieldAtom,
SelectedCell,
} from "@src/atoms/tableScope";
import { getFieldProp, getFieldType } from "@src/components/fields";
import { ColumnConfig } from "@src/types/table";
import { FieldType } from "@src/constants/fields";
const SUPPORTED_TYPES = new Set([
FieldType.shortText,
FieldType.longText,
FieldType.number,
FieldType.email,
FieldType.percentage,
FieldType.phone,
FieldType.richText,
FieldType.url,
FieldType.json,
]);
export function useMenuAction(
selectedCell: SelectedCell | null,
handleClose?: Function
) {
const { enqueueSnackbar } = useSnackbar();
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableRows] = useAtom(tableRowsAtom, tableScope);
const updateField = useSetAtom(updateFieldAtom, tableScope);
const [cellValue, setCellValue] = useState<string | undefined>();
const [selectedCol, setSelectedCol] = useState<ColumnConfig>();
const handleCopy = useCallback(async () => {
try {
if (cellValue !== undefined && cellValue !== null && cellValue !== "") {
await navigator.clipboard.writeText(
typeof cellValue === "object" ? JSON.stringify(cellValue) : cellValue
);
enqueueSnackbar("Copied");
} else {
await navigator.clipboard.writeText("");
}
} catch (error) {
enqueueSnackbar(`Failed to copy:${error}`, { variant: "error" });
}
if (handleClose) handleClose();
}, [cellValue, enqueueSnackbar, handleClose]);
const handleCut = useCallback(async () => {
try {
if (!selectedCell || !selectedCol || !cellValue) return;
if (cellValue !== undefined && cellValue !== null && cellValue !== "") {
await navigator.clipboard.writeText(
typeof cellValue === "object" ? JSON.stringify(cellValue) : cellValue
);
enqueueSnackbar("Copied");
} else {
await navigator.clipboard.writeText("");
}
if (cellValue !== undefined)
updateField({
path: selectedCell.path,
fieldName: selectedCol.fieldName,
value: undefined,
deleteField: true,
});
} catch (error) {
enqueueSnackbar(`Failed to cut: ${error}`, { variant: "error" });
}
if (handleClose) handleClose();
}, [
cellValue,
selectedCell,
selectedCol,
updateField,
enqueueSnackbar,
handleClose,
]);
const handlePaste = useCallback(async () => {
try {
if (!selectedCell || !selectedCol) return;
let text;
try {
text = await navigator.clipboard.readText();
} catch (e) {
enqueueSnackbar(`Read clilboard permission denied.`, {
variant: "error",
});
return;
}
const cellDataType = getFieldProp("dataType", getFieldType(selectedCol));
let parsed;
switch (cellDataType) {
case "number":
parsed = Number(text);
if (isNaN(parsed)) throw new Error(`${text} is not a number`);
break;
case "string":
parsed = text;
break;
default:
parsed = JSON.parse(text);
break;
}
updateField({
path: selectedCell.path,
fieldName: selectedCol.fieldName,
value: parsed,
});
} catch (error) {
enqueueSnackbar(
`${selectedCol?.type} field does not support the data type being pasted`,
{ variant: "error" }
);
}
if (handleClose) handleClose();
}, [selectedCell, selectedCol, updateField, enqueueSnackbar, handleClose]);
useEffect(() => {
if (!selectedCell) return setCellValue("");
const selectedCol = tableSchema.columns?.[selectedCell.columnKey];
if (!selectedCol) return setCellValue("");
setSelectedCol(selectedCol);
const selectedRow = find(tableRows, ["_rowy_ref.path", selectedCell.path]);
setCellValue(get(selectedRow, selectedCol.fieldName));
}, [selectedCell, tableSchema, tableRows]);
const checkEnabled = useCallback(
(func: Function) => {
return function () {
if (SUPPORTED_TYPES.has(selectedCol?.type)) {
return func();
} else {
enqueueSnackbar(
`${selectedCol?.type} field cannot be copied using keyboard shortcut`,
{
variant: "info",
}
);
}
};
},
[selectedCol]
);
return {
handleCopy: checkEnabled(handleCopy),
handleCut: checkEnabled(handleCut),
handlePaste: handlePaste,
cellValue,
};
}

View File

@@ -0,0 +1,29 @@
import {
MutableRefObject,
useCallback,
useRef,
useSyncExternalStore,
} from "react";
// NOTE: This is not the final solution. But is a potential solution for this problem.
export default function useStateWithRef<T>(
initialState: T
): [T, (newValue: T) => void, MutableRefObject<T>] {
const value = useRef<T>(initialState);
const get = useCallback(() => value.current, []);
const subscribers = useRef(new Set<() => void>());
const set = useCallback((newValue: T) => {
value.current = newValue;
subscribers.current.forEach((callback) => callback());
}, []);
const subscribe = useCallback((callback: () => void) => {
subscribers.current.add(callback);
return () => subscribers.current.delete(callback);
}, []);
const state = useSyncExternalStore(subscribe, get);
return [state, set, value];
}

View File

@@ -0,0 +1,22 @@
import { useEffect, useRef } from "react";
// This hook is used to log changes to props in a component.
export default function useTraceUpdates(
props: { [key: string]: any },
printMessage: string = "Changed props:"
) {
const prev = useRef(props);
useEffect(() => {
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
if (prev.current[k] !== v) {
// @ts-ignore
ps[k] = [prev.current[k], v];
}
return ps;
}, {});
if (Object.keys(changedProps).length > 0) {
console.log(printMessage, changedProps);
}
prev.current = props;
});
}

View File

@@ -187,22 +187,32 @@ export default function CloudLogItem({
)}
<Typography variant="inherit" noWrap className="log-preview">
{data.payload === "textPayload" && data.textPayload}
{get(data, "httpRequest.requestUrl")?.split(".run.app").pop()}
{data.payload === "jsonPayload" && (
<Typography
variant="inherit"
color="error"
fontWeight="bold"
component="span"
>
{data.jsonPayload.error}{" "}
</Typography>
{data.logName.endsWith("rowy-logging") && data.jsonPayload.payload ? (
<>
{typeof data.jsonPayload.payload === "string"
? data.jsonPayload.payload
: JSON.stringify(data.jsonPayload.payload)}
</>
) : (
<>
{data.payload === "textPayload" && data.textPayload}
{get(data, "httpRequest.requestUrl")?.split(".run.app").pop()}
{data.payload === "jsonPayload" && (
<Typography
variant="inherit"
color="error"
fontWeight="bold"
component="span"
>
{data.jsonPayload.error}{" "}
</Typography>
)}
{data.payload === "jsonPayload" &&
stringify(data.jsonPayload.body ?? data.jsonPayload, {
space: 2,
})}
</>
)}
{data.payload === "jsonPayload" &&
stringify(data.jsonPayload.body ?? data.jsonPayload, {
space: 2,
})}
</Typography>
</AccordionSummary>

View File

@@ -70,6 +70,13 @@ export default function CloudLogList({ items, ...props }: ICloudLogListProps) {
"jsonPayload.rowyUser.displayName",
// Webhook event
"jsonPayload.params.endpoint",
// Rowy Logging
"jsonPayload.functionType",
"jsonPayload.loggingSource",
"jsonPayload.extensionName",
"jsonPayload.extensionType",
"jsonPayload.webhookName",
"jsonPayload.fieldName",
]}
/>
</li>

View File

@@ -22,6 +22,12 @@ export const SEVERITY_LEVELS = {
EMERGENCY: "One or more systems are unusable.",
};
export const SEVERITY_LEVELS_ROWY = {
DEFAULT: "The log entry has no assigned severity level.",
WARNING: "Warning events might cause problems.",
ERROR: "Error events are likely to cause problems.",
};
export interface ICloudLogSeverityIconProps extends SvgIconProps {
severity: keyof typeof SEVERITY_LEVELS;
}

View File

@@ -1,6 +1,6 @@
import useSWR from "swr";
import { useAtom } from "jotai";
import { startCase } from "lodash-es";
import { startCase, upperCase } from "lodash-es";
import { ITableModalProps } from "@src/components/TableModals";
import {
@@ -12,9 +12,14 @@ import {
TextField,
InputAdornment,
Button,
Box,
CircularProgress,
Alert,
Link,
} from "@mui/material";
import RefreshIcon from "@mui/icons-material/Refresh";
import { CloudLogs as LogsIcon } from "@src/assets/icons";
import ClearIcon from "@mui/icons-material/Clear";
import Modal from "@src/components/Modal";
import TableToolbarButton from "@src/components/TableToolbar/TableToolbarButton";
@@ -23,7 +28,10 @@ import TimeRangeSelect from "./TimeRangeSelect";
import CloudLogList from "./CloudLogList";
import BuildLogs from "./BuildLogs";
import EmptyState from "@src/components/EmptyState";
import CloudLogSeverityIcon, { SEVERITY_LEVELS } from "./CloudLogSeverityIcon";
import CloudLogSeverityIcon, {
SEVERITY_LEVELS,
SEVERITY_LEVELS_ROWY,
} from "./CloudLogSeverityIcon";
import {
projectScope,
@@ -38,6 +46,8 @@ import {
cloudLogFiltersAtom,
} from "@src/atoms/tableScope";
import { cloudLogFetcher } from "./utils";
import { FieldType } from "@src/constants/fields";
import { WIKI_LINKS } from "@src/constants/externalLinks";
export default function CloudLogsModal({ onClose }: ITableModalProps) {
const [projectId] = useAtom(projectIdAtom, projectScope);
@@ -92,7 +102,7 @@ export default function CloudLogsModal({ onClose }: ITableModalProps) {
"&, & .MuiTab-root": {
minHeight: { md: "var(--dialog-title-height)" },
},
ml: { md: 18 },
ml: { md: 20 },
mr: { md: 40 / 8 + 3 },
minHeight: 32,
@@ -110,18 +120,35 @@ export default function CloudLogsModal({ onClose }: ITableModalProps) {
<ToggleButtonGroup
value={cloudLogFilters.type}
exclusive
onChange={(_, v) =>
onChange={(_, newType) => {
setCloudLogFilters((c) => ({
type: v,
type: newType,
timeRange: c.timeRange,
}))
}
}));
if (
[
"extension",
"webhook",
"column",
"audit",
"functions",
].includes(newType)
) {
setTimeout(() => {
mutate();
}, 0);
}
}}
aria-label="Filter by log type"
>
<ToggleButton value="webhook">Webhooks</ToggleButton>
<ToggleButton value="functions">Functions</ToggleButton>
<ToggleButton value="extension">Extension</ToggleButton>
<ToggleButton value="webhook">Webhook</ToggleButton>
<ToggleButton value="column">Column</ToggleButton>
<ToggleButton value="audit">Audit</ToggleButton>
<ToggleButton value="build">Build</ToggleButton>
<ToggleButton value="functions">
Functions (legacy)
</ToggleButton>
</ToggleButtonGroup>
) : (
<ToggleButtonGroup
@@ -139,209 +166,389 @@ export default function CloudLogsModal({ onClose }: ITableModalProps) {
</ToggleButtonGroup>
)}
{cloudLogFilters.type === "webhook" && (
<MultiSelect
multiple
label="Webhook:"
labelPlural="webhooks"
options={
Array.isArray(tableSchema.webhooks)
? tableSchema.webhooks.map((x) => ({
label: x.name,
value: x.endpoint,
}))
: []
}
value={cloudLogFilters.webhook ?? []}
onChange={(v) =>
setCloudLogFilters((prev) => ({ ...prev, webhook: v }))
}
TextFieldProps={{
id: "webhook",
className: "labelHorizontal",
sx: { "& .MuiInputBase-root": { width: 180 } },
fullWidth: false,
}}
itemRenderer={(option) => (
<>
{option.label}&nbsp;<code>{option.value}</code>
</>
)}
/>
)}
{cloudLogFilters.type === "audit" && (
<TextField
id="auditRowId"
label="Row ID:"
value={cloudLogFilters.auditRowId}
onChange={(e) =>
setCloudLogFilters((prev) => ({
...prev,
auditRowId: e.target.value,
}))
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{tableSettings.collection}/
</InputAdornment>
),
}}
className="labelHorizontal"
sx={{
"& .MuiInputBase-root, & .MuiInputBase-input": {
typography: "body2",
fontFamily: "mono",
},
"& .MuiInputAdornment-positionStart": {
m: "0 !important",
pointerEvents: "none",
},
"& .MuiInputBase-input": { pl: 0 },
}}
/>
)}
{/* Spacer */}
<div style={{ flexGrow: 1 }} />
{cloudLogFilters.type !== "build" && (
<>
{!isValidating && Array.isArray(data) && (
<Typography
variant="body2"
color="text.disabled"
display="block"
style={{ userSelect: "none" }}
>
{data.length} entries
</Typography>
)}
<MultiSelect
aria-label="Severity"
labelPlural="severity levels"
options={Object.keys(SEVERITY_LEVELS)}
value={cloudLogFilters.severity ?? []}
onChange={(severity) =>
setCloudLogFilters((prev) => ({ ...prev, severity }))
}
TextFieldProps={{
style: { width: 130 },
placeholder: "Severity",
SelectProps: {
renderValue: () => {
if (
!Array.isArray(cloudLogFilters.severity) ||
cloudLogFilters.severity.length === 0
)
return `Severity`;
if (cloudLogFilters.severity.length === 1)
return (
<>
Severity{" "}
<CloudLogSeverityIcon
severity={cloudLogFilters.severity[0]}
style={{ marginTop: -2, marginBottom: -7 }}
/>
</>
);
return `Severity (${cloudLogFilters.severity.length})`;
},
},
<Typography
variant="body2"
color="text.disabled"
display="block"
style={{ userSelect: "none" }}
>
{isValidating ? "" : `${data?.length ?? 0} entries`}
</Typography>
<TableToolbarButton
onClick={() => {
setCloudLogFilters((prev) => ({
...prev,
functionType: undefined,
loggingSource: undefined,
webhook: undefined,
extension: undefined,
severity: undefined,
}));
}}
itemRenderer={(option) => (
<>
<CloudLogSeverityIcon
severity={option.value}
sx={{ mr: 1 }}
/>
{startCase(option.value.toLowerCase())}
</>
)}
/>
<TimeRangeSelect
aria-label="Time range"
value={cloudLogFilters.timeRange}
onChange={(value) =>
setCloudLogFilters((c) => ({ ...c, timeRange: value }))
}
title="Clear Filters"
icon={<ClearIcon />}
disabled={isValidating}
/>
<TableToolbarButton
onClick={() => mutate()}
title="Refresh"
icon={<RefreshIcon />}
icon={
isValidating ? (
<CircularProgress size={15} thickness={4} />
) : (
<RefreshIcon />
)
}
disabled={isValidating}
/>
</>
)}
</Stack>
{isValidating && (
<LinearProgress
style={{
borderRadius: 0,
marginTop: -4,
marginBottom: -1,
minHeight: 4,
}}
/>
)}
{/* <code>{logQueryUrl}</code> */}
</>
}
>
{cloudLogFilters.type === "build" ? (
<BuildLogs />
) : Array.isArray(data) && data.length > 0 ? (
<>
<CloudLogList items={data} sx={{ mx: -1.5, mt: 1.5 }} />
{cloudLogFilters.timeRange.type !== "range" && (
<Button
style={{
marginLeft: "auto",
marginRight: "auto",
display: "flex",
}}
onClick={() =>
setCloudLogFilters((c) => ({
...c,
timeRange: {
...c.timeRange,
value: (c.timeRange as any).value * 2,
},
}))
}
>
Load more (last {cloudLogFilters.timeRange.value * 2}{" "}
{cloudLogFilters.timeRange.type})
</Button>
)}
</>
) : isValidating ? (
<EmptyState
Icon={LogsIcon}
message="Fetching logs…"
description={"\xa0"}
/>
) : (
<EmptyState
Icon={LogsIcon}
message="No logs"
description={
cloudLogFilters.type === "webhook" &&
(!Array.isArray(tableSchema.webhooks) ||
tableSchema.webhooks?.length === 0)
? "There are no webhooks in this table"
: cloudLogFilters.type === "audit" &&
tableSettings.audit === false
? "Auditing is disabled in this table"
: "\xa0"
}
/>
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
overflowY: "visible",
}}
>
{["extension", "webhook", "column", "audit", "functions"].includes(
cloudLogFilters.type
) ? (
<Stack
width={"100%"}
direction="row"
spacing={2}
justifyContent="flex-start"
alignItems="center"
sx={{
overflowX: "auto",
overflowY: "hidden",
margin: "8px 0",
flex: "0 0 32px",
}}
>
{cloudLogFilters.type === "functions" ? (
<Box width={"100%"}></Box>
) : null}
{cloudLogFilters.type === "extension" ? (
<>
<MultiSelect
multiple
aria-label={"Extension"}
labelPlural="extensions"
options={
Array.isArray(tableSchema.extensionObjects)
? tableSchema.extensionObjects.map((x) => ({
label: x.name,
value: x.name,
type: x.type,
}))
: []
}
value={cloudLogFilters.extension ?? []}
onChange={(v) =>
setCloudLogFilters((prev) => ({ ...prev, extension: v }))
}
TextFieldProps={{
id: "extension",
className: "labelHorizontal",
sx: {
width: "100%",
"& .MuiInputBase-root": { width: "100%" },
},
fullWidth: false,
placeholder: "Extension",
SelectProps: {
renderValue: () => {
if (cloudLogFilters?.extension?.length === 1) {
return `Extension (${cloudLogFilters.extension[0]})`;
} else if (cloudLogFilters?.extension?.length) {
return `Extension (${cloudLogFilters.extension.length})`;
} else {
return `Extension`;
}
},
},
}}
itemRenderer={(option) => (
<>
{option.label}&nbsp;<code>{option.type}</code>
</>
)}
/>
</>
) : null}
{cloudLogFilters.type === "webhook" ? (
<MultiSelect
multiple
aria-label="Webhook:"
labelPlural="webhooks"
options={
Array.isArray(tableSchema.webhooks)
? tableSchema.webhooks.map((x) => ({
label: x.name,
value: x.endpoint,
}))
: []
}
value={cloudLogFilters.webhook ?? []}
onChange={(v) =>
setCloudLogFilters((prev) => ({ ...prev, webhook: v }))
}
TextFieldProps={{
id: "webhook",
className: "labelHorizontal",
sx: {
width: "100%",
"& .MuiInputBase-root": { width: "100%" },
},
fullWidth: false,
SelectProps: {
renderValue: () => {
if (cloudLogFilters?.webhook?.length) {
return `Webhook (${cloudLogFilters.webhook.length})`;
} else {
return `Webhook`;
}
},
},
}}
itemRenderer={(option) => (
<>
{option.label}&nbsp;<code>{option.value}</code>
</>
)}
/>
) : null}
{cloudLogFilters.type === "column" ? (
<>
<MultiSelect
multiple
aria-label={"Column"}
options={Object.entries(tableSchema.columns ?? {})
.filter(
([key, config]) =>
config?.config?.defaultValue?.type === "dynamic" ||
[
FieldType.action,
FieldType.derivative,
FieldType.connector,
].includes(config.type)
)
.map(([key, config]) => ({
label: config.name,
value: key,
type: config.type,
}))}
value={cloudLogFilters.column ?? []}
onChange={(v) =>
setCloudLogFilters((prev) => ({ ...prev, column: v }))
}
TextFieldProps={{
id: "column",
className: "labelHorizontal",
sx: {
width: "100%",
"& .MuiInputBase-root": { width: "100%" },
},
fullWidth: false,
placeholder: "Column",
SelectProps: {
renderValue: () => {
if (cloudLogFilters?.column?.length === 1) {
return `Column (${cloudLogFilters.column[0]})`;
} else if (cloudLogFilters?.column?.length) {
return `Column (${cloudLogFilters.column.length})`;
} else {
return `Column`;
}
},
},
}}
itemRenderer={(option) => (
<>
{option.label}&nbsp;<code>{option.value}</code>&nbsp;
<code>{option.type}</code>
</>
)}
/>
</>
) : null}
{cloudLogFilters.type === "audit" ? (
<>
<TextField
id="auditRowId"
label="Row ID:"
value={cloudLogFilters.auditRowId}
onChange={(e) =>
setCloudLogFilters((prev) => ({
...prev,
auditRowId: e.target.value,
}))
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{tableSettings.collection}/
</InputAdornment>
),
}}
className="labelHorizontal"
sx={{
width: "100%",
"& .MuiInputBase-root, & .MuiInputBase-input": {
width: "100%",
typography: "body2",
fontFamily: "mono",
},
"& .MuiInputAdornment-positionStart": {
m: "0 !important",
pointerEvents: "none",
},
"& .MuiInputBase-input": { pl: 0 },
"& .MuiFormLabel-root": {
whiteSpace: "nowrap",
},
}}
/>
</>
) : null}
<MultiSelect
aria-label="Severity"
labelPlural="severity levels"
options={Object.keys(SEVERITY_LEVELS_ROWY)}
value={cloudLogFilters.severity ?? []}
onChange={(severity) =>
setCloudLogFilters((prev) => ({ ...prev, severity }))
}
TextFieldProps={{
style: { width: 200 },
placeholder: "Severity",
SelectProps: {
renderValue: () => {
if (
!Array.isArray(cloudLogFilters.severity) ||
cloudLogFilters.severity.length === 0
)
return `Severity`;
if (cloudLogFilters.severity.length === 1)
return (
<>
Severity{" "}
<CloudLogSeverityIcon
severity={cloudLogFilters.severity[0]}
style={{ marginTop: -2, marginBottom: -7 }}
/>
</>
);
return `Severity (${cloudLogFilters.severity.length})`;
},
},
}}
itemRenderer={(option) => (
<>
<CloudLogSeverityIcon
severity={option.value}
sx={{ mr: 1 }}
/>
{startCase(option.value.toLowerCase())}
</>
)}
/>
<TimeRangeSelect
aria-label="Time range"
value={cloudLogFilters.timeRange}
onChange={(value) =>
setCloudLogFilters((c) => ({ ...c, timeRange: value }))
}
/>
</Stack>
) : null}
{["extension", "webhook", "column"].includes(
cloudLogFilters.type
) && (
<Alert severity="info">
Remember to use <code>logging</code> functions,{" "}
<code>log,warning,error</code> for them to appear in the logs
bellow{" "}
<Link
component="a"
color="inherit"
target="_blank"
rel="noopener noreferrer"
href={WIKI_LINKS.cloudLogs}
>
Learn more
</Link>
</Alert>
)}
<Box
sx={{
overflowY: "scroll",
}}
>
{Array.isArray(data) && data.length > 0 ? (
<Box>
<CloudLogList items={data} sx={{ mx: -1.5, mt: 1.5 }} />
{cloudLogFilters.timeRange.type !== "range" && (
<Button
style={{
marginLeft: "auto",
marginRight: "auto",
display: "flex",
}}
onClick={() => {
setCloudLogFilters((c) => ({
...c,
timeRange: {
...c.timeRange,
value: (c.timeRange as any).value * 2,
},
}));
setTimeout(() => {
mutate();
}, 0);
}}
>
Load more (last {cloudLogFilters.timeRange.value * 2}{" "}
{cloudLogFilters.timeRange.type})
</Button>
)}
</Box>
) : isValidating ? (
<EmptyState
Icon={LogsIcon}
message="Fetching logs…"
description={"\xa0"}
/>
) : (
<EmptyState
Icon={LogsIcon}
message="No logs"
description={
cloudLogFilters.type !== "audit"
? "There are no logs matching the filters"
: cloudLogFilters.type === "audit" &&
tableSettings.audit === false
? "Auditing is disabled in this table"
: "\xa0"
}
/>
)}
</Box>
</Box>
)}
</Modal>
);

View File

@@ -19,7 +19,9 @@ export default function TimeRangeSelect({
...props
}: ITimeRangeSelectProps) {
return (
<fieldset style={{ appearance: "none", padding: 0, border: 0 }}>
<fieldset
style={{ appearance: "none", padding: 0, border: 0, display: "flex" }}
>
{value && value.type !== "range" && (
<TextField
aria-label={`Custom ${value.type} value`}

View File

@@ -12,21 +12,72 @@ export const cloudLogFetcher = (
// https://cloud.google.com/logging/docs/view/logging-query-language
let logQuery: string[] = [];
if (["extension", "webhook", "column"].includes(cloudLogFilters.type)) {
// mandatory filter to remove unwanted gcp diagnostic logs
logQuery.push(
["backend-scripts", "backend-function", "hooks"]
.map((loggingSource) => {
return `jsonPayload.loggingSource = "${loggingSource}"`;
})
.join(encodeURIComponent(" OR "))
);
}
switch (cloudLogFilters.type) {
case "extension":
logQuery.push(`logName = "projects/${projectId}/logs/rowy-logging"`);
logQuery.push(`jsonPayload.tablePath : "${tablePath}"`);
if (cloudLogFilters?.extension?.length) {
logQuery.push(
cloudLogFilters.extension
.map((extensionName) => {
return `jsonPayload.extensionName = "${extensionName}"`;
})
.join(encodeURIComponent(" OR "))
);
} else {
logQuery.push(`jsonPayload.functionType = "extension"`);
}
break;
case "webhook":
logQuery.push(
`logName = "projects/${projectId}/logs/rowy-webhook-events"`
);
logQuery.push(`jsonPayload.url : "${tablePath}"`);
if (
Array.isArray(cloudLogFilters.webhook) &&
cloudLogFilters.webhook.length > 0
)
logQuery.push(`jsonPayload.tablePath : "${tablePath}"`);
if (cloudLogFilters?.webhook?.length) {
logQuery.push(
cloudLogFilters.webhook
.map((id) => `jsonPayload.url : "${id}"`)
.join(encodeURIComponent(" OR "))
);
} else {
logQuery.push(`jsonPayload.functionType = "hooks"`);
}
break;
case "column":
logQuery.push(`jsonPayload.tablePath : "${tablePath}"`);
if (cloudLogFilters?.column?.length) {
logQuery.push(
cloudLogFilters.column
.map((column) => {
return `jsonPayload.fieldName = "${column}"`;
})
.join(encodeURIComponent(" OR "))
);
} else {
logQuery.push(
[
"connector",
"derivative-script",
"action",
"derivative-function",
"defaultValue",
]
.map((functionType) => {
return `jsonPayload.functionType = "${functionType}"`;
})
.join(encodeURIComponent(" OR "))
);
}
break;
case "audit":

View File

@@ -14,6 +14,7 @@ import {
import {
Extension as ExtensionIcon,
Copy as DuplicateIcon,
CloudLogs as LogsIcon,
} from "@src/assets/icons";
import EditIcon from "@mui/icons-material/EditOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
@@ -21,6 +22,12 @@ import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import EmptyState from "@src/components/EmptyState";
import { extensionNames, IExtension } from "./utils";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import { useSetAtom } from "jotai";
import {
cloudLogFiltersAtom,
tableModalAtom,
tableScope,
} from "@src/atoms/tableScope";
export interface IExtensionListProps {
extensions: IExtension[];
@@ -37,6 +44,9 @@ export default function ExtensionList({
handleEdit,
handleDelete,
}: IExtensionListProps) {
const setModal = useSetAtom(tableModalAtom, tableScope);
const setCloudLogFilters = useSetAtom(cloudLogFiltersAtom, tableScope);
if (extensions.length === 0)
return (
<EmptyState
@@ -91,6 +101,21 @@ export default function ExtensionList({
<DuplicateIcon />
</IconButton>
</Tooltip>
<Tooltip title="Logs">
<IconButton
aria-label="Logs"
onClick={() => {
setModal("cloudLogs");
setCloudLogFilters({
type: "extension",
timeRange: { type: "days", value: 7 },
extension: [extensionObject.name],
});
}}
>
<LogsIcon />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
aria-label="Edit"

View File

@@ -61,51 +61,61 @@ export interface IRuntimeOptions {
export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"];
const extensionBodyTemplate = {
task: `const extensionBody: TaskBody = async({row, db, change, ref}) => {
// task extensions are very flexible you can do anything from updating other documents in your database, to making an api request to 3rd party service.
task: `const extensionBody: TaskBody = async({row, db, change, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
// example:
// we can post notification to different discord channels based on row data
// Import any NPM package needed
// const vision = require('@google-cloud/vision');
// Task Extension is very flexible, you can do anything.
// From updating other documents in your database, to making an api request to 3rd party service.
// Example: post notification to different discord channels based on row data
/*
const topic = row.topic;
const channel = await db.collection('discordChannels').doc(topic).get();
const channelUrl = await channel.get("channelUrl");
const content = "Hello discord channel";
return fetch("https://discord.com/api/webhooks/"+channelUrl, {
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
content
})
}).then(async resp => {
const result = await resp.json()
if (resp.ok) console.info(result)
else console.error(result)
return fetch("https://discord.com/api/webhooks/"+channelUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
content
})
}).then(async resp => {
const result = await resp.json()
if (resp.ok) console.info(result)
else console.error(result)
})
*/
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
docSync: `const extensionBody: DocSyncBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
docSync: `const extensionBody: DocSyncBody = async({row, db, change, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
return ({
fieldsToSync: [], // a list of string of column names
row: row, // object of data to sync, usually the row itself
targetPath: "", // fill in the path here
})
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
historySnapshot: `const extensionBody: HistorySnapshotBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
historySnapshot: `const extensionBody: HistorySnapshotBody = async({row, db, change, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
return ({
trackedFields: [], // a list of string of column names
collectionId: "historySnapshots", // optionally change the sub-collection id of where the history snapshots are stored
})
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
algoliaIndex: `const extensionBody: AlgoliaIndexBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
algoliaIndex: `const extensionBody: AlgoliaIndexBody = async({row, db, change, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
return ({
fieldsToSync: [], // a list of string of column names
@@ -113,29 +123,38 @@ const extensionBodyTemplate = {
index: "", // algolia index to sync to
objectID: ref.id, // algolia object ID, ref.id is one possible choice
})
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
meiliIndex: `const extensionBody: MeiliIndexBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
meiliIndex: `const extensionBody: MeiliIndexBody = async({row, db, change, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
return({
fieldsToSync: [], // a list of string of column names
row: row, // object of data to sync, usually the row itself
index: "", // algolia index to sync to
objectID: ref.id, // algolia object ID, ref.id is one possible choice
})
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
bigqueryIndex: `const extensionBody: BigqueryIndexBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
bigqueryIndex: `const extensionBody: BigqueryIndexBody = async({row, db, change, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
return ({
fieldsToSync: [], // a list of string of column names
row: row, // object of data to sync, usually the row itself
index: "", // algolia index to sync to
objectID: ref.id, // algolia object ID, ref.id is one possible choice
})
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
slackMessage: `const extensionBody: SlackMessageBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
slackMessage: `const extensionBody: SlackMessageBody = async({row, db, change, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
// Import any NPM package needed
// const lodash = require('lodash');
return ({
channels: [], // a list of slack channel IDs in string
@@ -143,18 +162,23 @@ const extensionBodyTemplate = {
text: "", // the text parameter to pass in to slack api
attachments: [], // the attachments parameter to pass in to slack api
})
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
sendgridEmail: `const extensionBody: SendgridEmailBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
sendgridEmail: `const extensionBody: SendgridEmailBody = async({row, db, change, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
// Import any NPM package needed
// const lodash = require('lodash');
return ({
from: "Name<example@domain.com>", // send from field
personalizations: [
{
to: [{ name: "", email: "" }], // recipient
dynamic_template_data: {
}, // template parameters
},
{
to: [{ name: "", email: "" }], // recipient
dynamic_template_data: {
}, // template parameters
},
],
template_id: "", // sendgrid template ID
categories: [], // helper info to categorise sendgrid emails
@@ -163,9 +187,14 @@ const extensionBodyTemplate = {
// add any other custom args you want to pass to sendgrid events here
},
})
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
// Import any NPM package needed
// const lodash = require('lodash');
return ({
body: "",
@@ -173,56 +202,64 @@ const extensionBodyTemplate = {
method: "",
callback: ()=>{},
})
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
twilioMessage: `const extensionBody: TwilioMessageBody = async({row, db, change, ref}) => {
/**
*
* Setup twilio secret key: https://docs.rowy.io/extensions/twilio-message#secret-manager-setup
*
* You can add any code logic here to be able to customize your message
* or dynamically get the from or to numbers
*
**/
twilioMessage: `const extensionBody: TwilioMessageBody = async({row, db, change, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
// Import any NPM package needed
// const lodash = require('lodash');
// Setup twilio secret key: https://docs.rowy.io/extensions/twilio-message#secret-manager-setup
// Add any code here to customize your message or dynamically get the from/to numbers
return ({
from: "", // from phone number registered on twilio
to: "", // recipient phone number - eg: row.<fieldname>
body: "Hi there!" // message text
})
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
pushNotification: `const extensionBody: PushNotificationBody = async({row, db, change, ref}) => {
// you can FCM token from the row or from the user document in the database
pushNotification: `const extensionBody: PushNotificationBody = async({row, db, change, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
// Import any NPM package needed
// const lodash = require('lodash');
// You can use FCM token from the row or from the user document in the database
// const FCMtoken = row.FCMtoken
// or push through topic
// Or push through topic
const topicName = 'industry-tech';
// you can return single or array of notification payloads
return [{
notification: {
title: 'Hello!',
},
android: {
// You can return single or array of notification payloads
return [{
notification: {
imageUrl: 'https://thiscatdoesnotexist.com/'
}
},
apns: {
payload: {
aps: {
'mutable-content': 1
title: 'Hello!',
},
android: {
notification: {
imageUrl: 'https://thiscatdoesnotexist.com/'
}
},
fcm_options: {
image: 'https://thiscatdoesnotexist.com/'
}
},
webpush: {
headers: {
image: 'https://thiscatdoesnotexist.com/'
}
},
// topic: topicName, // add topic send to subscribers
// token: FCMtoken // add FCM token to send to specific user
}]
apns: {
payload: {
aps: {
'mutable-content': 1
}
},
fcm_options: {
image: 'https://thiscatdoesnotexist.com/'
}
},
webpush: {
headers: {
image: 'https://thiscatdoesnotexist.com/'
}
},
// topic: topicName, // add topic send to subscribers
// token: FCMtoken // add FCM token to send to specific user
}]
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
};
@@ -232,19 +269,23 @@ export function emptyExtensionObject(
): IExtension {
return {
name: `${type} extension`,
active: false,
active: true,
triggers: [],
type,
extensionBody: extensionBodyTemplate[type] ?? extensionBodyTemplate["task"],
requiredFields: [],
trackedFields: [],
conditions: `const condition: Condition = async({row, change}) => {
// feel free to add your own code logic here
conditions: `const condition: Condition = async({row, change, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("condition started")
return true;
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
lastEditor: user,
};
}
export function sparkToExtensionObjects(
sparkConfig: string,
user: IExtensionEditor

View File

@@ -72,7 +72,7 @@ export default function ImportAirtableWizard({ onClose }: ITableModalProps) {
const newColumns = uniqBy(
[...prev.newColumns, ...(value.newColumns ?? [])],
"key"
).filter((col) => pairs.some((pair) => pair.columnKey === col.key));
).filter((col) => pairs?.some((pair) => pair.columnKey === col.key));
return { ...prev, pairs, newColumns };
});
}, []);

View File

@@ -57,6 +57,9 @@ export default function Step1Columns({
config.pairs.map((pair) => pair.fieldKey)
);
const fieldKeys = Object.keys(airtableData.records[0].fields);
// When a field is selected to be imported
const handleSelect =
(field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -123,6 +126,47 @@ export default function Step1Columns({
}
};
const handleSelectAll = () => {
if (selectedFields.length !== fieldKeys.length) {
setSelectedFields(fieldKeys)
fieldKeys.forEach(field => {
// Try to match each field to a column in the table
const match =
find(tableColumns, (column) =>
column.label.toLowerCase().includes(field.toLowerCase())
)?.value ?? null;
const columnKey = camelCase(field);
const columnConfig: Partial<AirtableConfig> = {
pairs: [],
newColumns: [],
};
columnConfig.pairs = [
{ fieldKey: field, columnKey: match ?? columnKey },
];
if (!match) {
columnConfig.newColumns = [
{
name: field,
fieldName: columnKey,
key: columnKey,
type:
suggestType(airtableData.records, field) || FieldType.shortText,
index: -1,
config: {},
},
];
}
updateConfig(columnConfig);
})
} else {
setSelectedFields([])
setConfig((config) => ({ ...config, newColumns: [], pairs: [] }))
}
};
// When a field is mapped to a new column
const handleChange = (fieldKey: string) => (value: string) => {
if (!value) return;
@@ -159,7 +203,6 @@ export default function Step1Columns({
}
};
const fieldKeys = Object.keys(airtableData.records[0].fields);
return (
<div>
<Grid container spacing={7}>
@@ -180,14 +223,36 @@ export default function Step1Columns({
<Divider />
<FadeList>
<li>
<FormControlLabel
control={
<Checkbox
checked={selectedFields.length === fieldKeys.length}
indeterminate={
selectedFields.length !== 0 &&
selectedFields.length !== fieldKeys.length
}
onChange={handleSelectAll}
color="default"
/>
}
label={selectedFields.length == fieldKeys.length ? "Clear all" : "Select all"}
sx={{
height: 42,
mr: 0,
alignItems: "center",
"& .MuiFormControlLabel-label": { mt: 0, flex: 1 },
}}
/>
</li>
{fieldKeys.map((field) => {
const selected = selectedFields.indexOf(field) > -1;
const columnKey =
find(config.pairs, { fieldKey: field })?.columnKey ?? null;
const matchingColumn = columnKey
? tableSchema.columns?.[columnKey] ??
find(config.newColumns, { key: columnKey }) ??
null
find(config.newColumns, { key: columnKey }) ??
null
: null;
const isNewColumn = !!find(config.newColumns, { key: columnKey });
return (

View File

@@ -80,7 +80,7 @@ export default function ImportCsvWizard({ onClose }: ITableModalProps) {
const newColumns = uniqBy(
[...prev.newColumns, ...(value.newColumns ?? [])],
"key"
).filter((col) => pairs.some((pair) => pair.columnKey === col.key));
).filter((col) => pairs?.some((pair) => pair.columnKey === col.key));
return { ...prev, pairs, newColumns };
});

View File

@@ -64,6 +64,38 @@ export default function Step1Columns({
config.pairs.map((pair) => pair.csvKey)
);
const handleSelectAll = () => {
if (selectedFields.length !== csvData.columns.length) {
setSelectedFields(csvData.columns);
csvData.columns.forEach(field => {
// Try to match each field to a column in the table
const match =
find(tableColumns, (column) =>
column.label.toLowerCase().includes(field.toLowerCase())
)?.value ?? null;
const columnKey = camelCase(field);
const columnConfig: Partial<CsvConfig> = { pairs: [], newColumns: [] };
columnConfig.pairs = [{ csvKey: field, columnKey: match ?? columnKey }];
if (!match) {
columnConfig.newColumns = [
{
name: field,
fieldName: columnKey,
key: columnKey,
type: suggestType(csvData.rows, field) || FieldType.shortText,
index: -1,
config: {},
},
];
}
updateConfig(columnConfig);
})
} else {
setSelectedFields([])
setConfig((config) => ({ ...config, newColumns: [], pairs: [] }))
}
};
// When a field is selected to be imported
const handleSelect =
(field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -187,14 +219,36 @@ export default function Step1Columns({
<Divider />
<FadeList>
<li>
<FormControlLabel
control={
<Checkbox
checked={selectedFields.length === csvData.columns.length}
indeterminate={
selectedFields.length !== 0 &&
selectedFields.length !== csvData.columns.length
}
onChange={handleSelectAll}
color="default"
/>
}
label={selectedFields.length == csvData.columns.length ? "Clear all" : "Select all"}
sx={{
height: 42,
mr: 0,
alignItems: "center",
"& .MuiFormControlLabel-label": { mt: 0, flex: 1 },
}}
/>
</li>
{csvData.columns.map((field) => {
const selected = selectedFields.indexOf(field) > -1;
const columnKey =
find(config.pairs, { csvKey: field })?.columnKey ?? null;
const matchingColumn = columnKey
? tableSchema.columns?.[columnKey] ??
find(config.newColumns, { key: columnKey }) ??
null
find(config.newColumns, { key: columnKey }) ??
null
: null;
const isNewColumn = !!find(config.newColumns, { key: columnKey });

View File

@@ -117,7 +117,7 @@ export default function Step1Columns({ config, setConfig }: IStepProps) {
color="default"
/>
}
label="Select all"
label={selectedFields.length == allFields.length ? "Clear all" : "Select all"}
sx={{
height: 42,
mr: 0,

View File

@@ -37,12 +37,24 @@ export const REGEX_URL =
export const REGEX_HTML = /<\/?[a-z][\s\S]*>/;
const inferTypeFromValue = (value: any) => {
// by default the type of value is string, so trying to convert it to JSON/Object.
try {
value = JSON.parse(value);
} catch (e) {}
if (!value || typeof value === "function") return;
if (Array.isArray(value) && typeof value[0] === "string")
return FieldType.multiSelect;
if (typeof value === "boolean") return FieldType.checkbox;
if (isDate(value)) return FieldType.dateTime;
// trying to convert the value to date
if (typeof value !== "number" && +new Date(value)) {
// date and time are separated by a blank space, checking if time present.
if (value.split(" ").length > 1) {
return FieldType.dateTime;
}
return FieldType.date;
}
if (typeof value === "object") {
if ("hex" in value && "rgb" in value) return FieldType.color;
@@ -71,6 +83,7 @@ const inferTypeFromValue = (value: any) => {
export const suggestType = (data: { [key: string]: any }[], field: string) => {
const results: Record<string, number> = {};
// console.log(data)
data.forEach((row) => {
const result = inferTypeFromValue(row[field]);
if (!result) return;

View File

@@ -48,7 +48,7 @@ export default function AddWebhookButton({
}}
{...props}
>
Add webhook
Add Webhook
</Button>
<Menu

View File

@@ -21,17 +21,33 @@ const requestType = [
export const parserExtraLibs = [
requestType,
`type Parser = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
`type Parser = (
args: {
req: WebHookRequest;
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
res: {
send: (v:any)=>void;
sendStatus: (status:number)=>void
};
logging: RowyLogging;
}
) => Promise<any>;`,
];
export const conditionExtraLibs = [
requestType,
`type Condition = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
`type Condition = (
args: {
req: WebHookRequest;
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
res: {
send: (v:any)=>void;
sendStatus: (status:number)=>void;
};
logging: RowyLogging;
}
) => Promise<any>;`,
];
const additionalVariables = [
@@ -48,41 +64,42 @@ export const webhookBasic = {
extraLibs: parserExtraLibs,
template: (
table: TableSettings
) => `const basicParser: Parser = async({req, db,ref}) => {
// request is the request object from the webhook
// db is the database object
// ref is the reference to collection of the table
// the returned object will be added as a new row to the table
// eg: adding the webhook body as row
const {body} = req;
${
table.audit !== false
? `
// auditField
const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.metadata.serviceAccountUser()
return {
...body,
${table.auditFieldCreatedBy ?? "_createdBy"}
}
`
: `
return body;
`
}
}`,
) => `const basicParser: Parser = async({req, db, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("basicParser started")
// Import any NPM package needed
// const lodash = require('lodash');
// Optionally return an object to be added as a new row to the table
// Example: add the webhook body as row
const {body} = req;
${
table.audit !== false
? `const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.metadata.serviceAccountUser()
return {
...body,
${table.auditFieldCreatedBy ?? "_createdBy"}
}`
: `return body;`
}
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
condition: {
additionalVariables,
extraLibs: conditionExtraLibs,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
return true;
}`,
) => `const condition: Condition = async({ref, req, db, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("condition started")
return true;
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,

View File

@@ -13,32 +13,43 @@ export const webhookSendgrid = {
extraLibs: null,
template: (
table: TableSettings
) => `const sendgridParser: Parser = async ({ req, db, ref }) => {
const { body } = req
const eventHandler = async (sgEvent) => {
// Event handlers can be modiefed to preform different actions based on the sendgrid event
// List of events & docs : https://docs.sendgrid.com/for-developers/tracking-events/event#events
const { event, docPath } = sgEvent
// event param is provided by default
// however docPath or other custom parameter needs be passed in the custom_args variable in Sengrid Extension
return db.doc(docPath).update({ sgStatus: event })
}
//
if (Array.isArray(body)) {
// when multiple events are passed in one call
await Promise.allSettled(body.map(eventHandler))
} else eventHandler(body)
};`,
) => `const sendgridParser: Parser = async ({ req, db, ref, logging }) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("sendgridParser started")
// Import any NPM package needed
// const lodash = require('lodash');
const { body } = req
const eventHandler = async (sgEvent) => {
// Event handlers can be modiefed to preform different actions based on the sendgrid event
// List of events & docs : https://docs.sendgrid.com/for-developers/tracking-events/event#events
const { event, docPath } = sgEvent
// Event param is provided by default
// However docPath or other custom parameter needs be passed in the custom_args variable in Sengrid Extension
return db.doc(docPath).update({ sgStatus: event })
}
if (Array.isArray(body)) {
// Multiple events are passed in one call
await Promise.allSettled(body.map(eventHandler))
} else {
eventHandler(body)
}
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
};`,
},
condition: {
additionalVariables: null,
extraLibs: null,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
return true;
}`,
) => `const condition: Condition = async({ref, req, db, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("condition started")
return true;
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,

View File

@@ -17,16 +17,23 @@ export const webhookStripe = {
extraLibs: null,
template: (
table: TableSettings
) => `const sendgridParser: Parser = async ({ req, db, ref }) => {
const event = req.body
switch (event.type) {
case "payment_intent.succeeded":
break;
case "payment_intent.payment_failed":
break;
default:
// all other types
}
) => `const stripeParser: Parser = async ({ req, db, ref, logging }) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("stripeParser started")
// Import any NPM package needed
// const lodash = require('lodash');
const event = req.body
switch (event.type) {
case "payment_intent.succeeded":
break;
case "payment_intent.payment_failed":
break;
default:
// All other types
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}
};`,
},
condition: {
@@ -34,9 +41,12 @@ export const webhookStripe = {
extraLibs: null,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
) => `const condition: Condition = async({ref, req, db, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("condition started")
return true;
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (

View File

@@ -13,70 +13,75 @@ export const webhookTypeform = {
extraLibs: null,
template: (
table: TableSettings
) => `const typeformParser: Parser = async({req, db,ref}) =>{
// this reduces the form submission into a single object of key value pairs
// eg: {name: "John", age: 20}
// ⚠️ ensure that you have assigned ref values of the fields
// set the ref value to field key you would like to sync to
// docs: https://help.typeform.com/hc/en-us/articles/360050447552-Block-reference-format-restrictions
const {submitted_at,hidden,answers} = req.body.form_response
const submission = ({
_createdAt: submitted_at,
...hidden,
...answers.reduce((accRow, currAnswer) => {
switch (currAnswer.type) {
case "date":
return {
...accRow,
[currAnswer.field.ref]: new Date(currAnswer[currAnswer.type]),
};
case "choice":
return {
...accRow,
[currAnswer.field.ref]: currAnswer[currAnswer.type].label,
};
case "choices":
return {
...accRow,
[currAnswer.field.ref]: currAnswer[currAnswer.type].labels,
};
case "file_url":
default:
return {
...accRow,
[currAnswer.field.ref]: currAnswer[currAnswer.type],
};
}
}, {}),
})
${
table.audit !== false
? `
// auditField
const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.metadata.serviceAccountUser()
return {
...submission,
${table.auditFieldCreatedBy ?? "_createdBy"}
}
`
: `
return submission
`
}
};`,
) => `const typeformParser: Parser = async({req, db, ref, logging}) =>{
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("typeformParser started")
// Import any NPM package needed
// const lodash = require('lodash');
// This reduces the form submission into a single object of key value pairs
// Example: {name: "John", age: 20}
// ⚠️ Ensure that you have assigned ref values of the fields
// Set the ref value to field key you would like to sync to
// Docs: https://help.typeform.com/hc/en-us/articles/360050447552-Block-reference-format-restrictions
const {submitted_at,hidden,answers} = req.body.form_response
const submission = ({
_createdAt: submitted_at,
...hidden,
...answers.reduce((accRow, currAnswer) => {
switch (currAnswer.type) {
case "date":
return {
...accRow,
[currAnswer.field.ref]: new Date(currAnswer[currAnswer.type]),
};
case "choice":
return {
...accRow,
[currAnswer.field.ref]: currAnswer[currAnswer.type].label,
};
case "choices":
return {
...accRow,
[currAnswer.field.ref]: currAnswer[currAnswer.type].labels,
};
case "file_url":
default:
return {
...accRow,
[currAnswer.field.ref]: currAnswer[currAnswer.type],
};
}
}, {}),
})
${
table.audit !== false
? `const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.metadata.serviceAccountUser()
return {
...submission,
${table.auditFieldCreatedBy ?? "_createdBy"}
}`
: `return submission;`
}
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
};`,
},
condition: {
additionalVariables: null,
extraLibs: null,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
return true;
}`,
) => `const condition: Condition = async({ref, req, db, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("condition started")
return true;
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,

View File

@@ -14,41 +14,42 @@ export const webhook = {
extraLibs: null,
template: (
table: TableSettings
) => `const formParser: Parser = async({req, db,ref}) => {
// request is the request object from the webhook
// db is the database object
// ref is the reference to collection of the table
// the returned object will be added as a new row to the table
// eg: adding the webhook body as row
const {body} = req;
${
table.audit !== false
? `
// auditField
const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.metadata.serviceAccountUser()
return {
...body,
${table.auditFieldCreatedBy ?? "_createdBy"}
}
`
: `
return body;
`
}
}`,
) => `const formParser: Parser = async({req, db, ref, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("formParser started")
// Import any NPM package needed
// const lodash = require('lodash');
// Optionally return an object to be added as a new row to the table
// Example: add the webhook body as row
const {body} = req;
${
table.audit !== false
? `const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.metadata.serviceAccountUser()
return {
...body,
${table.auditFieldCreatedBy ?? "_createdBy"}
}`
: `return body;`
}
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
condition: {
additionalVariables: null,
extraLibs: null,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
return true;
}`,
) => `const condition: Condition = async({ref, req, db, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("condition started")
return true;
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,

View File

@@ -86,7 +86,7 @@ export default function WebhookModal({
disableBackdropClick
disableEscapeKeyDown
fullWidth
title={`${mode === "add" ? "Add" : "Update"} webhook: ${
title={`${mode === "add" ? "Add" : "Update"} Webhook: ${
webhookNames[webhookObject.type]
}`}
sx={{

View File

@@ -26,17 +26,37 @@ const requestType = [
export const parserExtraLibs = [
requestType,
`type Parser = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
`type Parser = (
args: {
req: WebHookRequest;
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
res: {
send: (v:any)=>void;
sendStatus: (status:number)=>void
};
logging: RowyLogging;
auth:firebaseauth.BaseAuth;
storage:firebasestorage.Storage;
}
) => Promise<any>;`,
];
export const conditionExtraLibs = [
requestType,
`type Condition = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
`type Condition = (
args: {
req:WebHookRequest,
db: FirebaseFirestore.Firestore,
ref: FirebaseFirestore.CollectionReference,
res: {
send: (v:any)=>void
sendStatus: (status:number)=>void
};
logging: RowyLogging;
auth:firebaseauth.BaseAuth;
storage:firebasestorage.Storage;
}
) => Promise<any>;`,
];
const additionalVariables = [
@@ -56,7 +76,7 @@ export const webhookNames: Record<WebhookType, string> = {
// twitter: "Twitter",
stripe: "Stripe",
basic: "Basic",
webform: "Web form",
webform: "Web Form",
};
export interface IWebhookEditor {
@@ -99,7 +119,7 @@ export function emptyWebhookObject(
): IWebhook {
return {
name: `${type} webhook`,
active: false,
active: true,
endpoint: generateId(),
type,
parser: webhookSchemas[type].parser?.template(table),

View File

@@ -3,10 +3,12 @@ type Condition = (args: {
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
res: Response;
logging: RowyLogging;
}) => Promise<any>;
type Parser = (args: {
req: WebHookRequest;
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
logging: RowyLogging;
}) => Promise<any>;

View File

@@ -1,6 +1,11 @@
import { find } from "lodash-es";
import { useAtom } from "jotai";
import { Field, FieldType } from "@rowy/form-builder";
import { TableSettingsDialogState } from "@src/atoms/projectScope";
import {
projectIdAtom,
projectScope,
TableSettingsDialogState,
} from "@src/atoms/projectScope";
import { Link, ListItemText, Typography } from "@mui/material";
import OpenInNewIcon from "@src/components/InlineOpenInNewIcon";
@@ -9,6 +14,21 @@ import WarningIcon from "@mui/icons-material/WarningAmber";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import { FieldType as TableFieldType } from "@src/constants/fields";
function CollectionLink() {
const [projectId] = useAtom(projectIdAtom, projectScope);
return (
<Link
href={`https://console.firebase.google.com/project/${projectId}/firestore/data`}
target="_blank"
rel="noopener noreferrer"
>
Your collections
<OpenInNewIcon />
</Link>
);
}
export const tableSettings = (
mode: TableSettingsDialogState["mode"],
roles: string[] | undefined,
@@ -105,14 +125,7 @@ export const tableSettings = (
) : (
"Choose which Firestore collection to display."
)}{" "}
<Link
href={`https://console.firebase.google.com/project/_/firestore/data`}
target="_blank"
rel="noopener noreferrer"
>
Your collections
<OpenInNewIcon />
</Link>
<CollectionLink />
</>
),
AddButtonProps: {
@@ -311,6 +324,17 @@ export const tableSettings = (
label: "Suggested Firestore Rules",
watchedField: "collection",
},
{
step: "accessControls",
type: FieldType.multiSelect,
name: "modifiableBy",
label: "Modifiable by",
labelPlural: "Modifier Roles",
options: roles ?? [],
defaultValue: ["ADMIN"],
required: true,
freeText: true,
},
// Step 4: Auditing
{

View File

@@ -112,7 +112,9 @@ export default function Filters() {
setLocalFilters(filtersToApply);
// Reset order so we dont have to make a new index
setTableSorts([]);
if (filtersToApply.length) {
setTableSorts([]);
}
}, [
hasTableFilters,
hasUserFilters,

View File

@@ -29,6 +29,7 @@ import {
projectScope,
userSettingsAtom,
updateUserSettingsAtom,
userRolesAtom,
} from "@src/atoms/projectScope";
import {
tableScope,
@@ -41,6 +42,9 @@ export default function HiddenFields() {
const buttonRef = useRef<HTMLButtonElement>(null);
const [userSettings] = useAtom(userSettingsAtom, projectScope);
const [userRoles] = useAtom(userRolesAtom, projectScope);
const canEditColumns =
userRoles.includes("ADMIN") || userRoles.includes("OPS");
const [tableId] = useAtom(tableIdAtom, tableScope);
const [open, setOpen] = useState(false);
@@ -76,7 +80,7 @@ export default function HiddenFields() {
setOpen(false);
};
// disable drag if search box is not empty
// disable drag if search box is not empty and user does not have permission
const [disableDrag, setDisableDrag] = useState<boolean>(false);
const renderOption: AutocompleteProps<
any,
@@ -92,7 +96,7 @@ export default function HiddenFields() {
<Draggable
draggableId={option.value}
index={option.index}
isDragDisabled={disableDrag}
isDragDisabled={disableDrag || !canEditColumns}
>
{(provided) => (
<li {...props} ref={provided.innerRef} {...provided.draggableProps}>
@@ -106,7 +110,9 @@ export default function HiddenFields() {
{
marginRight: "6px",
opacity: (theme) =>
disableDrag ? theme.palette.action.disabledOpacity : 1,
disableDrag || !canEditColumns
? theme.palette.action.disabledOpacity
: 1,
},
]}
/>
@@ -159,7 +165,8 @@ export default function HiddenFields() {
// updates column on drag end
function handleOnDragEnd(result: DropResult) {
if (!result.destination) return;
if (!result.destination || result.destination.index === result.source.index)
return;
updateColumn({
key: result.draggableId,
config: {},
@@ -169,7 +176,7 @@ export default function HiddenFields() {
// checks whether to disable reordering when search filter is applied
function checkToDisableDrag(e: ChangeEvent<HTMLInputElement>) {
setDisableDrag(e.target.value !== "");
setDisableDrag(e.target.value !== "" || !canEditColumns);
}
const ListboxComponent = forwardRef(function ListboxComponent(
@@ -205,7 +212,9 @@ export default function HiddenFields() {
<>
<ButtonWithStatus
startIcon={<VisibilityOffIcon />}
onClick={() => setOpen((o) => !o)}
onClick={() => {
setOpen((o) => !o);
}}
active={hiddenFields.length > 0}
ref={buttonRef}
>

View File

@@ -91,7 +91,7 @@ export default function ImportData({ render, PopoverProps }: IImportDataProps) {
variant="fullWidth"
>
<Tab
label="CSV/TSV"
label="CSV/TSV/JSON"
value="csv"
onClick={() => (importMethodRef.current = ImportMethod.csv)}
/>

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useAtom, useSetAtom } from "jotai";
import { parse } from "csv-parse/browser/esm";
import { parse as parseJSON } from "json2csv";
import { useDropzone } from "react-dropzone";
import { useDebouncedCallback } from "use-debounce";
import { useSnackbar } from "notistack";
@@ -34,7 +35,83 @@ export enum ImportMethod {
url = "url",
}
export default function ImportFromCsv() {
enum FileType {
CSV = "text/csv",
TSV = "text/tab-separated-values",
JSON = "application/json",
}
// extract the column names and return the names
function extractFields(data: JSON[]): string[] {
let columns = new Set<string>();
for (let jsonRow of data) {
columns = new Set([...columns, ...Object.keys(jsonRow)]);
}
columns.delete("id");
return [...columns];
}
function convertJSONToCSV(rawData: string): string | false {
let rawDataJSONified: JSON[];
try {
rawDataJSONified = JSON.parse(rawData);
} catch (e) {
return false;
}
if (rawDataJSONified.length < 1) {
return false;
}
const fields = extractFields(rawDataJSONified);
const opts = {
fields,
transforms: [
(value: any) => {
// if the value is an array, join it with a comma
for (let key in value) {
if (Array.isArray(value[key])) {
value[key] = value[key].join(",");
}
}
return value;
},
],
};
try {
const csv = parseJSON(rawDataJSONified, opts);
return csv;
} catch (err) {
return false;
}
}
function hasProperJsonStructure(raw: string) {
try {
raw = JSON.parse(raw);
const type = Object.prototype.toString.call(raw);
// we don't want '[object Object]'
return type === "[object Array]";
} catch (err) {
return false;
}
}
function checkIsJson(raw: string): boolean {
raw = typeof raw !== "string" ? JSON.stringify(raw) : raw;
try {
raw = JSON.parse(raw);
} catch (e) {
return false;
}
if (typeof raw === "object" && raw !== null) {
return true;
}
return false;
}
export default function ImportFromFile() {
const [{ importType: importTypeCsv, csvData }, setImportCsv] = useAtom(
importCsvAtom,
tableScope
@@ -55,6 +132,20 @@ export default function ImportFromCsv() {
};
}, [setImportCsv]);
const parseFile = useCallback((rawData: string) => {
if (importTypeRef.current === "json") {
if (!hasProperJsonStructure(rawData)) {
return setError("Invalid Structure! It must be an Array");
}
const converted = convertJSONToCSV(rawData);
if (!converted) {
return setError("No columns detected");
}
rawData = converted;
}
parseCsv(rawData);
}, []);
const parseCsv = useCallback(
(csvString: string) =>
parse(csvString, { delimiter: [",", "\t"] }, (err, rows) => {
@@ -71,6 +162,7 @@ export default function ImportFromCsv() {
{}
)
);
console.log(mappedRows);
setImportCsv({
importType: importTypeRef.current,
csvData: { columns, rows: mappedRows },
@@ -86,13 +178,17 @@ export default function ImportFromCsv() {
async (acceptedFiles: File[]) => {
try {
const file = acceptedFiles[0];
const reader = new FileReader();
reader.onload = (event: any) => parseCsv(event.target.result);
reader.readAsText(file);
importTypeRef.current =
file.type === "text/tab-separated-values" ? "tsv" : "csv";
file.type === FileType.TSV
? "tsv"
: file.type === FileType.JSON
? "json"
: "csv";
const reader = new FileReader();
reader.onload = (event: any) => parseFile(event.target.result);
reader.readAsText(file);
} catch (error) {
enqueueSnackbar(`Please import a .tsv or .csv file`, {
enqueueSnackbar(`Please import a .tsv or .csv or .json file`, {
variant: "error",
anchorOrigin: {
vertical: "top",
@@ -107,10 +203,14 @@ export default function ImportFromCsv() {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: false,
accept: ["text/csv", "text/tab-separated-values"],
accept: [FileType.CSV, FileType.TSV, FileType.JSON],
});
function setDataTypeRef(data: string) {
if (checkIsJson(data)) {
return (importTypeRef.current = "json");
}
const getFirstLine = data?.match(/^(.*)/)?.[0];
/*
* Catching edge case with regex
@@ -128,8 +228,8 @@ export default function ImportFromCsv() {
: (importTypeRef.current = "csv");
}
const handlePaste = useDebouncedCallback((value: string) => {
parseCsv(value);
setDataTypeRef(value);
parseFile(value);
}, 1000);
const handleUrl = useDebouncedCallback((value: string) => {
@@ -138,8 +238,8 @@ export default function ImportFromCsv() {
fetch(value, { mode: "no-cors" })
.then((res) => res.text())
.then((data) => {
parseCsv(data);
setDataTypeRef(data);
parseFile(data);
setLoading(false);
})
.catch((e) => {
@@ -217,7 +317,7 @@ export default function ImportFromCsv() {
<input {...getInputProps()} />
{isDragActive ? (
<Typography variant="button" color="primary">
Drop CSV or TSV file here
Drop CSV or TSV or JSON file here
</Typography>
) : (
<>
@@ -227,8 +327,8 @@ export default function ImportFromCsv() {
<Grid item>
<Typography variant="button" color="inherit">
{validCsv
? "Valid CSV or TSV"
: "Click to upload or drop CSV or TSV file here"}
? "Valid CSV or TSV or JSON"
: "Click to upload or drop CSV or TSV or JSON file here"}
</Typography>
</Grid>
</>
@@ -249,7 +349,7 @@ export default function ImportFromCsv() {
inputProps={{ minRows: 3 }}
autoFocus
fullWidth
label="Paste CSV or TSV text"
label="Paste CSV or TSV or JSON text"
placeholder="column, column, …"
onChange={(e) => {
if (csvData !== null)
@@ -279,7 +379,7 @@ export default function ImportFromCsv() {
variant="filled"
autoFocus
fullWidth
label="Paste URL to CSV or TSV file"
label="Paste URL to CSV or TSV or JSON file"
placeholder="https://"
onChange={(e) => {
if (csvData !== null)

View File

@@ -44,7 +44,7 @@ function StepComponent({ setComplete }: ITableTutorialStepComponentProps) {
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
useEffect(() => {
if (
tableColumnsOrdered.some(
tableColumnsOrdered?.some(
(c) =>
c.type === FieldType.rating && c.name.toLowerCase().includes("rating")
)

View File

@@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import {
Card,
@@ -25,6 +25,7 @@ export default function TableCard({
link,
actions,
}: ITableCardProps) {
const navigate = useNavigate();
return (
<Card style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<CardActionArea component={Link} to={link}>
@@ -46,7 +47,11 @@ export default function TableCard({
backgroundColor: "action.input",
borderRadius: 1,
overflow: "hidden",
"&:hover": {
cursor: "pointer",
},
}}
onClick={() => navigate(link)}
>
<Box
sx={{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,169 @@
import { lazy, Suspense, useMemo } from "react";
import { useDebouncedCallback } from "use-debounce";
import { useAtom } from "jotai";
import MultiSelect from "@rowy/multiselect";
import { Grid, InputLabel, Stack, FormHelperText } from "@mui/material";
import {
tableColumnsOrderedAtom,
tableSchemaAtom,
tableScope,
} from "@src/atoms/tableScope";
import FieldSkeleton from "@src/components/SideDrawer/FieldSkeleton";
import { ISettingsProps } from "@src/components/fields/types";
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
import { DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT } from "@src/components/Table";
import { ColumnConfig } from "@src/types/table";
import { defaultFn, listenerFieldTypes, outputFieldTypes } from "./util";
import PreviewTable from "./PreviewTable";
import { getFieldProp } from "..";
/* eslint-disable import/no-webpack-loader-syntax */
import formulaDefs from "!!raw-loader!./formula.d.ts";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper";
const CodeEditor = lazy(
() =>
import("@src/components/CodeEditor" /* webpackChunkName: "CodeEditor" */)
);
const diagnosticsOptions = {
noSemanticValidation: false,
noSyntaxValidation: false,
noSuggestionDiagnostics: true,
};
export default function Settings({
config,
fieldName,
onChange,
onBlur,
errors,
}: ISettingsProps) {
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const returnType = getFieldProp("dataType", config.renderFieldType) ?? "any";
const formulaFn = config?.formulaFn ? config.formulaFn : defaultFn;
const previewTableSchema = useMemo(() => {
const columns = tableSchema.columns || {};
return {
...tableSchema,
columns: Object.keys(columns).reduce((previewSchema, key) => {
if ((config.listenerFields || []).includes(columns[key].fieldName)) {
previewSchema[key] = {
...columns[key],
fixed: false,
width: DEFAULT_COL_WIDTH,
};
}
if (columns[key].fieldName === fieldName) {
previewSchema[key] = {
...columns[key],
config,
fixed: true,
};
}
return previewSchema;
}, {} as { [key: string]: ColumnConfig }),
rowHeight: DEFAULT_ROW_HEIGHT,
};
}, [config, fieldName, tableSchema]);
return (
<Stack spacing={1}>
<Grid container direction="row" spacing={2} flexWrap="nowrap">
<Grid item xs={12} md={6}>
<MultiSelect
label="Listener fields"
options={tableColumnsOrdered
.filter((c) => listenerFieldTypes.includes(c.type))
.map((c) => ({ label: c.name, value: c.key }))}
value={config.listenerFields ?? []}
onChange={onChange("listenerFields")}
TextFieldProps={{
helperText: (
<>
{errors.listenerFields && (
<FormHelperText error style={{ margin: 0 }}>
{errors.listenerFields}
</FormHelperText>
)}
<FormHelperText error={false} style={{ margin: 0 }}>
Changes to these fields will trigger the evaluation of the
column.
</FormHelperText>
</>
),
FormHelperTextProps: { component: "div" } as any,
required: true,
error: errors.listenerFields,
onBlur,
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<FieldsDropdown
label="Output field type"
value={config.renderFieldType}
options={outputFieldTypes}
onChange={(value) => {
onChange("renderFieldType")(value);
}}
TextFieldProps={{
required: true,
error: errors.renderFieldType,
helperText: errors.renderFieldType,
onBlur,
}}
/>
</Grid>
</Grid>
<InputLabel>Formula script</InputLabel>
<div>
<CodeEditorHelper
disableDefaultVariables
disableSecretManagerLink
disableCloudManagerLink
docLink={WIKI_LINKS.fieldTypesFormula}
additionalVariables={[
{
key: "row",
description: `row has the value of doc.data() it has type definitions using this table's schema, but you can only access formula's listener fields.`,
},
{
key: "ref",
description: `reference object that holds the readonly reference of the row document.(i.e ref.id)`,
},
]}
/>
<Suspense fallback={<FieldSkeleton height={200} />}>
<CodeEditor
diagnosticsOptions={diagnosticsOptions}
value={formulaFn}
extraLibs={[
formulaDefs.replace(
`"PLACEHOLDER_OUTPUT_TYPE"`,
`${returnType} | Promise<${returnType}>`
),
]}
onChange={useDebouncedCallback(onChange("formulaFn"), 300)}
/>
</Suspense>
</div>
<PreviewTable tableSchema={previewTableSchema} />
</Stack>
);
}
export const settingsValidator = (config: any) => {
const errors: Record<string, any> = {};
if (config.error) errors.error = config.error;
return errors;
};

View File

@@ -0,0 +1,83 @@
import { useCallback, useEffect } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useAtomCallback } from "jotai/utils";
import { cloneDeep, findIndex, sortBy } from "lodash-es";
import {
_deleteRowDbAtom,
_updateRowDbAtom,
tableNextPageAtom,
tableRowsDbAtom,
tableSchemaAtom,
tableScope,
tableSettingsAtom,
} from "@src/atoms/tableScope";
import { TableRow, TableSchema } from "@src/types/table";
import { updateRowData } from "@src/utils/table";
import { serializeRef } from "./util";
const TableSourcePreview = ({ tableSchema }: { tableSchema: TableSchema }) => {
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const setTableSchemaAtom = useSetAtom(tableSchemaAtom, tableScope);
const setRows = useSetAtom(tableRowsDbAtom, tableScope);
useEffect(() => {
setRows(
["preview-doc-1", "preview-doc-2", "preview-doc-3"].map((docId) => ({
_rowy_ref: serializeRef(`${tableSettings.collection}/${docId}`),
}))
);
}, [setRows, tableSettings.collection]);
useEffect(() => {
setTableSchemaAtom(() => ({
...tableSchema,
_rowy_ref: "preview",
}));
}, [tableSchema, setTableSchemaAtom]);
const readRowsDb = useAtomCallback(
useCallback((get) => get(tableRowsDbAtom) || [], []),
tableScope
);
const setUpdateRowDb = useSetAtom(_updateRowDbAtom, tableScope);
setUpdateRowDb(() => async (path: string, update: Partial<TableRow>) => {
const rows = await readRowsDb();
const index = findIndex(rows, ["_rowy_ref.path", path]);
if (index === -1) {
setRows(
sortBy(
[
...rows,
{ ...update, _rowy_ref: { id: path.split("/").pop()!, path } },
],
["_rowy_ref.id"]
)
);
} else {
const updatedRows = [...rows];
updatedRows[index] = cloneDeep(rows[index]);
updatedRows[index] = updateRowData(updatedRows[index], update);
setRows(updatedRows);
}
return Promise.resolve();
});
const setDeleteRowDb = useSetAtom(_deleteRowDbAtom, tableScope);
setDeleteRowDb(() => async (path: string) => {
const rows = await readRowsDb();
const index = findIndex(rows, ["_rowy_ref.path", path]);
if (index > -1) {
setRows(rows.filter((_, idx) => idx !== index));
}
return Promise.resolve();
});
const setNextPageAtom = useSetAtom(tableNextPageAtom, tableScope);
setNextPageAtom({ loading: false, available: false });
return null;
};
export default TableSourcePreview;

View File

@@ -0,0 +1,9 @@
type RowRef<T> = { id: string; path: string; parent: T };
interface Ref extends RowRef<Ref> {}
type FormulaContext = {
row: Row;
ref: Ref;
};
type Formula = (context: FormulaContext) => "PLACEHOLDER_OUTPUT_TYPE";

View File

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

View File

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

View File

@@ -0,0 +1,144 @@
import { useMemo, useRef } from "react";
import { isEqual } from "lodash-es";
import { FieldType } from "@src/constants/fields";
import ShortTextDisplayCell from "@src/components/fields/ShortText/DisplayCell";
import LongTextDisplayCell from "@src/components/fields/LongText/DisplayCell";
import RichTextDisplayCell from "@src/components/fields/RichText/DisplayCell";
import UrlDisplayCell from "@src/components/fields/Url/DisplayCell";
import NumberDisplayCell from "@src/components/fields/Number/DisplayCell";
import CheckboxDisplayCell from "@src/components/fields/Checkbox/DisplayCell";
import PercentageDisplayCell from "@src/components/fields/Percentage/DisplayCell";
import RatingDisplayCell from "@src/components/fields/Rating/DisplayCell";
import SliderDisplayCell from "@src/components/fields/Slider/DisplayCell";
import SingleSelectDisplayCell from "@src/components/fields/SingleSelect/DisplayCell";
import MultiSelectDisplayCell from "@src/components/fields/MultiSelect/DisplayCell";
import ColorDisplayCell from "@src/components/fields/Color/DisplayCell";
import GeoPointDisplayCell from "@src/components/fields/GeoPoint/DisplayCell";
import DateDisplayCell from "@src/components/fields/Date/DisplayCell";
import DateTimeDisplayCell from "@src/components/fields/DateTime/DisplayCell";
import ImageDisplayCell from "@src/components/fields/Image/DisplayCell";
import FileDisplayCell from "@src/components/fields/File/DisplayCell";
import JsonDisplayCell from "@src/components/fields/Json/DisplayCell";
import CodeDisplayCell from "@src/components/fields/Code/DisplayCell";
import MarkdownDisplayCell from "@src/components/fields/Markdown/DisplayCell";
import CreatedByDisplayCell from "@src/components/fields/CreatedBy/DisplayCell";
import { TableRowRef } from "@src/types/table";
import { DocumentData, DocumentReference } from "firebase/firestore";
export function useDeepCompareMemoize<T>(value: T) {
const ref = useRef<T>(value);
const signalRef = useRef<number>(0);
if (!isEqual(value, ref.current)) {
ref.current = value;
signalRef.current += 1;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => ref.current, [signalRef.current]);
}
export const listenerFieldTypes = Object.values(FieldType).filter(
(type) =>
![FieldType.formula, FieldType.subTable, FieldType.last].includes(type)
);
export const outputFieldTypes = Object.values(FieldType).filter(
(type) =>
![
FieldType.formula,
FieldType.derivative,
FieldType.action,
FieldType.status,
FieldType.aggregate,
FieldType.connectService,
FieldType.connectTable,
FieldType.connector,
FieldType.duration,
FieldType.subTable,
FieldType.reference,
FieldType.createdAt,
FieldType.createdBy,
FieldType.updatedAt,
FieldType.updatedBy,
FieldType.last,
].includes(type)
);
export const defaultFn = `const formula:Formula = async ({ row, ref })=> {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
// Example:
// return row.a + row.b;
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}
`;
export const getDisplayCell = (type: FieldType) => {
switch (type) {
case FieldType.longText:
return LongTextDisplayCell;
case FieldType.richText:
return RichTextDisplayCell;
case FieldType.url:
return UrlDisplayCell;
case FieldType.number:
return NumberDisplayCell;
case FieldType.checkbox:
return CheckboxDisplayCell;
case FieldType.percentage:
return PercentageDisplayCell;
case FieldType.rating:
return RatingDisplayCell;
case FieldType.slider:
return SliderDisplayCell;
case FieldType.singleSelect:
return SingleSelectDisplayCell;
case FieldType.multiSelect:
return MultiSelectDisplayCell;
case FieldType.color:
return ColorDisplayCell;
case FieldType.geoPoint:
return GeoPointDisplayCell;
case FieldType.date:
return DateDisplayCell;
case FieldType.dateTime:
return DateTimeDisplayCell;
case FieldType.image:
return ImageDisplayCell;
case FieldType.file:
return FileDisplayCell;
case FieldType.json:
return JsonDisplayCell;
case FieldType.code:
return CodeDisplayCell;
case FieldType.markdown:
return MarkdownDisplayCell;
case FieldType.createdBy:
return CreatedByDisplayCell;
default:
return ShortTextDisplayCell;
}
};
export const serializeRef = (path: string, maxDepth = 20) => {
const pathArr = path.split("/");
const serializedRef = {
path: pathArr.join("/"),
id: pathArr.pop(),
} as any;
let curr: TableRowRef | Partial<DocumentReference<DocumentData>> =
serializedRef;
let depth = 0;
while (pathArr.length > 0 && curr && depth < maxDepth) {
(curr.parent as any) = {
path: pathArr.join("/"),
id: pathArr.pop(),
} as Partial<DocumentReference<DocumentData>>;
curr = curr.parent as any;
maxDepth++;
}
return serializedRef;
};

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More