mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
@@ -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.
|
||||
|
||||
@@ -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 table’s 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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ↗">
|
||||
<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 ↗">
|
||||
<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 ↗">
|
||||
<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 ↗">
|
||||
<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 ↗">
|
||||
<IconButton
|
||||
|
||||
1
src/components/CodeEditor/extensions.d.ts
vendored
1
src/components/CodeEditor/extensions.d.ts
vendored
@@ -26,6 +26,7 @@ type ExtensionContext = {
|
||||
extensionBody: any;
|
||||
};
|
||||
RULES_UTILS: any;
|
||||
logging: RowyLogging;
|
||||
};
|
||||
|
||||
// extension body definition
|
||||
|
||||
5
src/components/CodeEditor/rowy.d.ts
vendored
5
src/components/CodeEditor/rowy.d.ts
vendored
@@ -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: {
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -4,5 +4,6 @@ type DefaultValueContext = {
|
||||
storage: firebasestorage.Storage;
|
||||
db: FirebaseFirestore.Firestore;
|
||||
auth: firebaseauth.BaseAuth;
|
||||
logging: RowyLogging;
|
||||
};
|
||||
type DefaultValue = (context: DefaultValueContext) => "PLACEHOLDER_OUTPUT_TYPE";
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@ export const ColumnHeader = memo(function ColumnHeader({
|
||||
sortKey={sortKey}
|
||||
currentSort={currentSort}
|
||||
tabIndex={focusInsideCell ? 0 : -1}
|
||||
canEditColumns={canEditColumns}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
98
src/components/Table/ColumnHeader/useSaveTableSorts.tsx
Normal file
98
src/components/Table/ColumnHeader/useSaveTableSorts.tsx
Normal 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;
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 cell’s data has updated, update the local value if
|
||||
// it’s 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(() => {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
99
src/components/Table/useHotKey.tsx
Normal file
99
src/components/Table/useHotKey.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
161
src/components/Table/useMenuAction.tsx
Normal file
161
src/components/Table/useMenuAction.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
29
src/components/Table/useStateWithRef.ts
Normal file
29
src/components/Table/useStateWithRef.ts
Normal 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];
|
||||
}
|
||||
22
src/components/Table/useTraceUpdates.ts
Normal file
22
src/components/Table/useTraceUpdates.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} <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} <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} <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} <code>{option.value}</code>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function AddWebhookButton({
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
Add webhook…
|
||||
Add Webhook…
|
||||
</Button>
|
||||
|
||||
<Menu
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -112,7 +112,9 @@ export default function Filters() {
|
||||
|
||||
setLocalFilters(filtersToApply);
|
||||
// Reset order so we don’t have to make a new index
|
||||
setTableSorts([]);
|
||||
if (filtersToApply.length) {
|
||||
setTableSorts([]);
|
||||
}
|
||||
}, [
|
||||
hasTableFilters,
|
||||
hasUserFilters,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -4,7 +4,7 @@ import { get } from "lodash-es";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { httpsCallable } from "firebase/functions";
|
||||
|
||||
import { Fab, FabProps } from "@mui/material";
|
||||
import { Button, Fab, FabProps, Link } from "@mui/material";
|
||||
import RunIcon from "@mui/icons-material/PlayArrow";
|
||||
import RedoIcon from "@mui/icons-material/Refresh";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
@@ -118,11 +118,32 @@ export default function ActionFab({
|
||||
} else {
|
||||
result = await handleCallableAction(data);
|
||||
}
|
||||
const { message, success } = result ?? {};
|
||||
const { message, success, link } = result ?? {};
|
||||
enqueueSnackbar(
|
||||
typeof message === "string" ? message : JSON.stringify(message),
|
||||
{
|
||||
variant: success ? "success" : "error",
|
||||
action: link ? (
|
||||
typeof link === "string" ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
href={link}
|
||||
component={Link}
|
||||
target="_blank"
|
||||
>
|
||||
Link
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
href={link.url}
|
||||
component={Link}
|
||||
variant="outlined"
|
||||
target="_blank"
|
||||
>
|
||||
{link.label}
|
||||
</Button>
|
||||
)
|
||||
) : undefined,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -130,7 +130,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
: config?.runFn
|
||||
? config.runFn
|
||||
: config?.script
|
||||
? `const action:Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
|
||||
? `const action:Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
|
||||
${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")}
|
||||
}`
|
||||
: RUN_ACTION_TEMPLATE;
|
||||
@@ -140,7 +140,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
: config.undoFn
|
||||
? config.undoFn
|
||||
: get(config, "undo.script")
|
||||
? `const action : Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
|
||||
? `const action : Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
|
||||
${get(config, "undo.script")}
|
||||
}`
|
||||
: UNDO_ACTION_TEMPLATE;
|
||||
|
||||
2
src/components/fields/Action/action.d.ts
vendored
2
src/components/fields/Action/action.d.ts
vendored
@@ -15,12 +15,14 @@ type ActionContext = {
|
||||
auth: firebaseauth.BaseAuth;
|
||||
actionParams: actionParams;
|
||||
user: ActionUser;
|
||||
logging: RowyLogging;
|
||||
};
|
||||
|
||||
type ActionResult = {
|
||||
success: boolean;
|
||||
message?: any;
|
||||
status?: string | number | null | undefined;
|
||||
link?: string | { url: string; label: string };
|
||||
};
|
||||
|
||||
type Action = (context: ActionContext) => Promise<ActionResult> | ActionResult;
|
||||
|
||||
@@ -30,6 +30,7 @@ export const config: IFieldConfig = {
|
||||
SideDrawerField,
|
||||
settings: Settings,
|
||||
requireConfiguration: true,
|
||||
requireCloudFunction: true,
|
||||
sortKey: "status",
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -1,54 +1,67 @@
|
||||
export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
|
||||
// Write your action code here
|
||||
// for example:
|
||||
// const authToken = await rowy.secrets.get("service")
|
||||
// try {
|
||||
// const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{
|
||||
// method: 'PUT',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// 'Authorization': authToken
|
||||
// },
|
||||
// body: JSON.stringify(row)
|
||||
// })
|
||||
//
|
||||
// return {
|
||||
// success: true,
|
||||
// message: 'User updated successfully on example service',
|
||||
// status: "upto date"
|
||||
// }
|
||||
// } catch (error) {
|
||||
// return {
|
||||
// success: false,
|
||||
// message: 'User update failed on example service',
|
||||
// }
|
||||
// }
|
||||
// checkout the documentation for more info: https://docs.rowy.io/field-types/action#script
|
||||
}`;
|
||||
export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("action started")
|
||||
|
||||
// Import any NPM package needed
|
||||
// const lodash = require('lodash');
|
||||
|
||||
// Example:
|
||||
/*
|
||||
const authToken = await rowy.secrets.get("service")
|
||||
try {
|
||||
const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authToken
|
||||
},
|
||||
body: JSON.stringify(row)
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
message: 'User updated successfully on example service',
|
||||
status: "upto date"
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User update failed on example service',
|
||||
}
|
||||
}
|
||||
*/
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`;
|
||||
|
||||
export const UNDO_ACTION_TEMPLATE = `const action : Action = async ({row,ref,db,storage,auth,actionParams,user}) => {
|
||||
// Write your undo code here
|
||||
// for example:
|
||||
// const authToken = await rowy.secrets.get("service")
|
||||
// try {
|
||||
// const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{
|
||||
// method: 'DELETE',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// 'Authorization': authToken
|
||||
// },
|
||||
// body: JSON.stringify(row)
|
||||
// })
|
||||
//
|
||||
// return {
|
||||
// success: true,
|
||||
// message: 'User deleted successfully on example service',
|
||||
// status: null
|
||||
// }
|
||||
// } catch (error) {
|
||||
// return {
|
||||
// success: false,
|
||||
// message: 'User delete failed on example service',
|
||||
// }
|
||||
// }
|
||||
}`;
|
||||
export const UNDO_ACTION_TEMPLATE = `const action : Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("action started")
|
||||
|
||||
// Import any NPM package needed
|
||||
// const lodash = require('lodash');
|
||||
|
||||
// Example:
|
||||
/*
|
||||
const authToken = await rowy.secrets.get("service")
|
||||
try {
|
||||
const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authToken
|
||||
},
|
||||
body: JSON.stringify(row)
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
message: 'User deleted successfully on example service',
|
||||
status: null
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User delete failed on example service',
|
||||
}
|
||||
}
|
||||
*/
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`;
|
||||
|
||||
24
src/components/fields/Array/DisplayCell.tsx
Normal file
24
src/components/fields/Array/DisplayCell.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useTheme } from "@mui/material";
|
||||
import { IDisplayCellProps } from "@src/components/fields/types";
|
||||
|
||||
export default function Array({ value }: IDisplayCellProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxHeight: "100%",
|
||||
whiteSpace: "pre-wrap",
|
||||
lineHeight: theme.typography.body2.lineHeight,
|
||||
fontFamily: theme.typography.fontFamilyMono,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(value, null, 4)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/components/fields/Array/SideDrawerField/AddButton.tsx
Normal file
92
src/components/fields/Array/SideDrawerField/AddButton.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Select,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
|
||||
import { ChevronDown as ArrowDropDownIcon } from "@src/assets/icons";
|
||||
import { FieldType } from "@src/components/fields/types";
|
||||
import { getFieldProp } from "@src/components/fields";
|
||||
|
||||
import {
|
||||
ArraySupportedFields,
|
||||
ArraySupportedFiledTypes,
|
||||
} from "./SupportedTypes";
|
||||
|
||||
function AddButton({ handleAddNew }: { handleAddNew: Function }) {
|
||||
const anchorEl = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [fieldType, setFieldType] = useState<ArraySupportedFiledTypes>(
|
||||
FieldType.shortText
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup
|
||||
variant="contained"
|
||||
color="primary"
|
||||
aria-label="Split button"
|
||||
sx={{ width: "fit-content" }}
|
||||
ref={anchorEl}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => handleAddNew(fieldType)}
|
||||
startIcon={<AddIcon />}
|
||||
>
|
||||
Add {getFieldProp("name", fieldType)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
aria-label="Select add element"
|
||||
aria-haspopup="menu"
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => setOpen(true)}
|
||||
id="add-row-menu-button"
|
||||
aria-controls={open ? "add-new-element" : undefined}
|
||||
aria-expanded={open ? "true" : "false"}
|
||||
>
|
||||
<ArrowDropDownIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<Select
|
||||
id="add-new-element"
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
label="Add new element"
|
||||
style={{ display: "none" }}
|
||||
value={fieldType}
|
||||
onChange={(e) => setFieldType(e.target.value as typeof fieldType)}
|
||||
MenuProps={{
|
||||
anchorEl: anchorEl.current,
|
||||
MenuListProps: { "aria-labelledby": "add-row-menu-button" },
|
||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
||||
transformOrigin: { horizontal: "left", vertical: "top" },
|
||||
}}
|
||||
>
|
||||
{ArraySupportedFields.map((fieldType, i) => (
|
||||
<MenuItem value={fieldType} disabled={false} key={i + ""}>
|
||||
<ListItemText
|
||||
primary={getFieldProp("name", fieldType)}
|
||||
secondary={getFieldProp("description", fieldType)}
|
||||
secondaryTypographyProps={{
|
||||
variant: "caption",
|
||||
whiteSpace: "pre-line",
|
||||
}}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddButton;
|
||||
108
src/components/fields/Array/SideDrawerField/SupportedTypes.ts
Normal file
108
src/components/fields/Array/SideDrawerField/SupportedTypes.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { DocumentReference, GeoPoint, Timestamp } from "firebase/firestore";
|
||||
|
||||
import { FieldType } from "@src/components/fields/types";
|
||||
|
||||
import NumberValueSidebar from "@src/components/fields/Number/SideDrawerField";
|
||||
import ShortTextValueSidebar from "@src/components/fields/ShortText/SideDrawerField";
|
||||
import JsonValueSidebar from "@src/components/fields/Json/SideDrawerField";
|
||||
import CheckBoxValueSidebar from "@src/components/fields/Checkbox/SideDrawerField";
|
||||
import GeoPointValueSidebar from "@src/components/fields/GeoPoint/SideDrawerField";
|
||||
import DateTimeValueSidebar from "@src/components/fields/DateTime/SideDrawerField";
|
||||
import ReferenceValueSidebar from "@src/components/fields/Reference/SideDrawerField";
|
||||
|
||||
export const ArraySupportedFields = [
|
||||
FieldType.number,
|
||||
FieldType.shortText,
|
||||
FieldType.json,
|
||||
FieldType.checkbox,
|
||||
FieldType.geoPoint,
|
||||
FieldType.dateTime,
|
||||
FieldType.reference,
|
||||
] as const;
|
||||
|
||||
export type ArraySupportedFiledTypes = typeof ArraySupportedFields[number];
|
||||
|
||||
export const SupportedTypes = {
|
||||
[FieldType.number]: {
|
||||
Sidebar: NumberValueSidebar,
|
||||
initialValue: 0,
|
||||
dataType: "common",
|
||||
instance: Object,
|
||||
},
|
||||
[FieldType.shortText]: {
|
||||
Sidebar: ShortTextValueSidebar,
|
||||
initialValue: "",
|
||||
dataType: "common",
|
||||
instance: Object,
|
||||
},
|
||||
[FieldType.checkbox]: {
|
||||
Sidebar: CheckBoxValueSidebar,
|
||||
initialValue: false,
|
||||
dataType: "common",
|
||||
instance: Object,
|
||||
},
|
||||
[FieldType.json]: {
|
||||
Sidebar: JsonValueSidebar,
|
||||
initialValue: {},
|
||||
sx: [
|
||||
{
|
||||
marginTop: "24px",
|
||||
},
|
||||
],
|
||||
dataType: "common",
|
||||
instance: Object,
|
||||
},
|
||||
[FieldType.geoPoint]: {
|
||||
Sidebar: GeoPointValueSidebar,
|
||||
initialValue: new GeoPoint(0, 0),
|
||||
dataType: "firestore-type",
|
||||
instance: GeoPoint,
|
||||
},
|
||||
[FieldType.dateTime]: {
|
||||
Sidebar: DateTimeValueSidebar,
|
||||
initialValue: Timestamp.now(),
|
||||
dataType: "firestore-type",
|
||||
instance: Timestamp,
|
||||
},
|
||||
[FieldType.reference]: {
|
||||
Sidebar: ReferenceValueSidebar,
|
||||
initialValue: null,
|
||||
dataType: "firestore-type",
|
||||
instance: DocumentReference,
|
||||
},
|
||||
};
|
||||
|
||||
export function detectType(value: any): ArraySupportedFiledTypes {
|
||||
if (value === null) {
|
||||
return FieldType.reference;
|
||||
}
|
||||
for (const supportedField of ArraySupportedFields) {
|
||||
if (SupportedTypes[supportedField].dataType === "firestore-type") {
|
||||
if (value instanceof SupportedTypes[supportedField].instance) {
|
||||
return supportedField;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case "bigint":
|
||||
case "number": {
|
||||
return FieldType.number;
|
||||
}
|
||||
case "string": {
|
||||
return FieldType.shortText;
|
||||
}
|
||||
case "boolean": {
|
||||
return FieldType.checkbox;
|
||||
}
|
||||
case "object": {
|
||||
if (+new Date(value)) {
|
||||
return FieldType.dateTime;
|
||||
}
|
||||
return FieldType.json;
|
||||
}
|
||||
default: {
|
||||
return FieldType.shortText;
|
||||
}
|
||||
}
|
||||
}
|
||||
205
src/components/fields/Array/SideDrawerField/index.tsx
Normal file
205
src/components/fields/Array/SideDrawerField/index.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
DropResult,
|
||||
} from "react-beautiful-dnd";
|
||||
|
||||
import { Stack, Box, Button, ListItem, List } from "@mui/material";
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
import DragIndicatorOutlinedIcon from "@mui/icons-material/DragIndicatorOutlined";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutline";
|
||||
|
||||
import { FieldType, ISideDrawerFieldProps } from "@src/components/fields/types";
|
||||
import { TableRowRef } from "@src/types/table";
|
||||
|
||||
import AddButton from "./AddButton";
|
||||
import { getPseudoColumn } from "./utils";
|
||||
import {
|
||||
ArraySupportedFiledTypes,
|
||||
detectType,
|
||||
SupportedTypes,
|
||||
} from "./SupportedTypes";
|
||||
|
||||
function ArrayFieldInput({
|
||||
onChange,
|
||||
value,
|
||||
_rowy_ref,
|
||||
index,
|
||||
onRemove,
|
||||
onSubmit,
|
||||
id,
|
||||
}: {
|
||||
index: number;
|
||||
onRemove: (index: number) => void;
|
||||
onChange: (value: any) => void;
|
||||
value: any;
|
||||
onSubmit: () => void;
|
||||
_rowy_ref: TableRowRef;
|
||||
id: string;
|
||||
}) {
|
||||
const typeDetected = detectType(value);
|
||||
|
||||
const Sidebar = SupportedTypes[typeDetected].Sidebar;
|
||||
return (
|
||||
<Draggable draggableId={id} index={index} isDragDisabled={false}>
|
||||
{(provided) => (
|
||||
<ListItem
|
||||
sx={[{ padding: 0, marginBottom: "12px" }]}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<Box
|
||||
sx={[{ position: "relative", height: "1.5rem" }]}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<DragIndicatorOutlinedIcon
|
||||
color="disabled"
|
||||
sx={[
|
||||
{
|
||||
marginRight: "6px",
|
||||
opacity: (theme) =>
|
||||
false ? theme.palette.action.disabledOpacity : 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
<Stack
|
||||
width={"100%"}
|
||||
sx={
|
||||
typeDetected === FieldType.json
|
||||
? SupportedTypes[typeDetected].sx
|
||||
: null
|
||||
}
|
||||
>
|
||||
<Sidebar
|
||||
disabled={false}
|
||||
onDirty={onChange}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
column={getPseudoColumn(typeDetected, index, value)}
|
||||
value={value}
|
||||
_rowy_ref={_rowy_ref}
|
||||
/>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={[{ position: "relative", height: "1.5rem" }]}
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
<DeleteIcon
|
||||
color="disabled"
|
||||
sx={[
|
||||
{
|
||||
marginLeft: "6px",
|
||||
":hover": {
|
||||
cursor: "pointer",
|
||||
color: "error.main",
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</ListItem>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArraySideDrawerField({
|
||||
column,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled,
|
||||
_rowy_ref,
|
||||
onDirty,
|
||||
...props
|
||||
}: ISideDrawerFieldProps) {
|
||||
const handleAddNew = (fieldType: ArraySupportedFiledTypes) => {
|
||||
onChange([...(value || []), SupportedTypes[fieldType].initialValue]);
|
||||
onDirty(true);
|
||||
};
|
||||
const handleChange = (newValue_: any, indexUpdated: number) => {
|
||||
onChange(
|
||||
[...(value || [])].map((v: any, i) => {
|
||||
if (i === indexUpdated) {
|
||||
return newValue_;
|
||||
}
|
||||
|
||||
return v;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
value.splice(index, 1);
|
||||
onChange([...value]);
|
||||
onDirty(true);
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
const handleClearField = () => {
|
||||
onChange([]);
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
function handleOnDragEnd(result: DropResult) {
|
||||
if (
|
||||
!result.destination ||
|
||||
result.destination.index === result.source.index
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const list = Array.from(value);
|
||||
const [removed] = list.splice(result.source.index, 1);
|
||||
list.splice(result.destination.index, 0, removed);
|
||||
onChange(list);
|
||||
onSubmit();
|
||||
}
|
||||
|
||||
if (value === undefined || Array.isArray(value)) {
|
||||
return (
|
||||
<>
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<Droppable droppableId="columns_manager" direction="vertical">
|
||||
{(provided) => (
|
||||
<List {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{(value || []).map((v: any, index: number) => (
|
||||
<ArrayFieldInput
|
||||
key={`index-${index}-value`}
|
||||
id={`index-${index}-value`}
|
||||
_rowy_ref={_rowy_ref}
|
||||
value={v}
|
||||
onChange={(newValue) => handleChange(newValue, index)}
|
||||
onRemove={handleRemove}
|
||||
index={index}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</List>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<AddButton handleAddNew={handleAddNew} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Box component="pre" my="0">
|
||||
{JSON.stringify(value, null, 4)}
|
||||
</Box>
|
||||
<Button
|
||||
sx={{ mt: 1, width: "fit-content" }}
|
||||
onClick={handleClearField}
|
||||
variant="text"
|
||||
color="warning"
|
||||
startIcon={<ClearIcon />}
|
||||
>
|
||||
Clear field
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
59
src/components/fields/Array/SideDrawerField/utils.ts
Normal file
59
src/components/fields/Array/SideDrawerField/utils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ColumnConfig } from "@src/types/table";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
import { ArraySupportedFiledTypes } from "./SupportedTypes";
|
||||
import { GeoPoint, DocumentReference } from "firebase/firestore";
|
||||
export function getPseudoColumn(
|
||||
fieldType: FieldType,
|
||||
index: number,
|
||||
value: any
|
||||
): ColumnConfig {
|
||||
return {
|
||||
fieldName: (+new Date()).toString(),
|
||||
index: index,
|
||||
key: (+new Date()).toString(),
|
||||
name: value + "",
|
||||
type: fieldType,
|
||||
};
|
||||
}
|
||||
|
||||
// archive: detectType / TODO: remove
|
||||
export function detectType(value: any): ArraySupportedFiledTypes {
|
||||
if (value === null) {
|
||||
return FieldType.reference;
|
||||
}
|
||||
console.log(typeof GeoPoint);
|
||||
console.log(value instanceof DocumentReference, value);
|
||||
|
||||
if (typeof value === "object") {
|
||||
const keys = Object.keys(value);
|
||||
// console.log({ keys, value }, typeof value);
|
||||
if (keys.length === 2) {
|
||||
if (keys.includes("_lat") && keys.includes("_long")) {
|
||||
return FieldType.geoPoint;
|
||||
}
|
||||
if (keys.includes("nanoseconds") && keys.includes("seconds")) {
|
||||
return FieldType.dateTime;
|
||||
}
|
||||
}
|
||||
if (+new Date(value)) {
|
||||
return FieldType.dateTime;
|
||||
}
|
||||
return FieldType.json;
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case "bigint":
|
||||
case "number": {
|
||||
return FieldType.number;
|
||||
}
|
||||
case "string": {
|
||||
return FieldType.shortText;
|
||||
}
|
||||
case "boolean": {
|
||||
return FieldType.checkbox;
|
||||
}
|
||||
default: {
|
||||
return FieldType.shortText;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/components/fields/Array/index.tsx
Normal file
30
src/components/fields/Array/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { lazy } from "react";
|
||||
import DataArrayIcon from "@mui/icons-material/DataArray";
|
||||
|
||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||
|
||||
import DisplayCell from "./DisplayCell";
|
||||
|
||||
const SideDrawerField = lazy(
|
||||
() =>
|
||||
import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Array" */)
|
||||
);
|
||||
|
||||
export const config: IFieldConfig = {
|
||||
type: FieldType.array,
|
||||
name: "Array",
|
||||
group: "Code",
|
||||
dataType: "object",
|
||||
initialValue: [],
|
||||
initializable: true,
|
||||
icon: <DataArrayIcon />,
|
||||
description:
|
||||
"Connects to a sub-table in the current row. Also displays number of rows inside the sub-table. Max sub-table depth: 100.",
|
||||
TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", {
|
||||
popoverProps: { PaperProps: { sx: { p: 1, minWidth: "200px" } } },
|
||||
}),
|
||||
SideDrawerField,
|
||||
requireConfiguration: false,
|
||||
};
|
||||
export default config;
|
||||
@@ -127,7 +127,9 @@ export default function PopupContents({
|
||||
<Grid item xs>
|
||||
<List sx={{ overflowY: "auto" }}>
|
||||
{hits.map((hit) => {
|
||||
const isSelected = selectedValues.some((v) => v === hit[elementId]);
|
||||
const isSelected = selectedValues?.some(
|
||||
(v) => v === hit[elementId]
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
key={get(hit, elementId)}
|
||||
|
||||
@@ -15,6 +15,7 @@ type ConnectorContext = {
|
||||
auth: firebaseauth.BaseAuth;
|
||||
query: string;
|
||||
user: ConnectorUser;
|
||||
logging: RowyLogging;
|
||||
};
|
||||
type ConnectorResult = any[];
|
||||
type Connector = (
|
||||
|
||||
@@ -34,6 +34,7 @@ export const config: IFieldConfig = {
|
||||
}),
|
||||
SideDrawerField,
|
||||
requireConfiguration: true,
|
||||
requireCloudFunction: true,
|
||||
settings: Settings,
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -11,9 +11,15 @@ export const replacer = (data: any) => (m: string, key: string) => {
|
||||
return get(data, objKey, defaultValue);
|
||||
};
|
||||
|
||||
export const baseFunction = `const connectorFn: Connector = async ({query, row, user}) => {
|
||||
// TODO: Implement your service function here
|
||||
export const baseFunction = `const connectorFn: Connector = async ({query, row, user, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("connectorFn started")
|
||||
|
||||
// Import any NPM package needed
|
||||
// const lodash = require('lodash');
|
||||
|
||||
return [];
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
};`;
|
||||
|
||||
export const getLabel = (config: any, row: TableRow) => {
|
||||
|
||||
@@ -34,10 +34,9 @@ export const config: IFieldConfig = {
|
||||
SideDrawerField,
|
||||
filter: { operators: filterOperators, valueFormatter },
|
||||
settings: Settings,
|
||||
csvImportParser: (value, config) =>
|
||||
parse(value, config?.format ?? DATE_FORMAT, new Date()),
|
||||
csvImportParser: (value, config) => parse(value, DATE_FORMAT, new Date()),
|
||||
csvExportFormatter: (value: any, config?: any) =>
|
||||
format(value.toDate(), config?.format ?? DATE_FORMAT),
|
||||
format(value.toDate(), DATE_FORMAT),
|
||||
};
|
||||
export default config;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lazy } from "react";
|
||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||
import { parseJSON, format } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||
|
||||
import DateTimeIcon from "@mui/icons-material/AccessTime";
|
||||
@@ -46,9 +46,9 @@ export const config: IFieldConfig = {
|
||||
customInput: FilterCustomInput,
|
||||
},
|
||||
settings: Settings,
|
||||
csvImportParser: (value) => parseJSON(value).getTime(),
|
||||
csvImportParser: (value) => new Date(value),
|
||||
csvExportFormatter: (value: any, config?: any) =>
|
||||
format(value.toDate(), config?.format ?? DATE_TIME_FORMAT),
|
||||
format(value.toDate(), DATE_TIME_FORMAT),
|
||||
};
|
||||
export default config;
|
||||
|
||||
|
||||
@@ -2,13 +2,17 @@ import { useAtom } from "jotai";
|
||||
import { find, get } from "lodash-es";
|
||||
import { useSnackbar } from "notistack";
|
||||
|
||||
import { Button } from "@mui/material";
|
||||
import ReEvalIcon from "@mui/icons-material/ReplayOutlined";
|
||||
import EvalIcon from "@mui/icons-material/PlayCircleOutline";
|
||||
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||
|
||||
import {
|
||||
projectScope,
|
||||
compatibleRowyRunVersionAtom,
|
||||
rowyRunAtom,
|
||||
projectIdAtom,
|
||||
projectSettingsAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import {
|
||||
tableScope,
|
||||
@@ -34,6 +38,8 @@ export const ContextMenuActions: IFieldConfig["contextMenuActions"] = (
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||
const [projectId] = useAtom(projectIdAtom, projectScope);
|
||||
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
|
||||
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
|
||||
const [compatibleRowyRunVersion] = useAtom(
|
||||
compatibleRowyRunVersionAtom,
|
||||
@@ -76,8 +82,32 @@ export const ContextMenuActions: IFieldConfig["contextMenuActions"] = (
|
||||
} else {
|
||||
enqueueSnackbar("Cell evaluated", { variant: "success" });
|
||||
}
|
||||
} catch (error) {
|
||||
enqueueSnackbar(`Failed: ${error}`, { variant: "error" });
|
||||
} catch (error: any) {
|
||||
if (error.message === "Failed to fetch") {
|
||||
enqueueSnackbar(
|
||||
"Evaluation failed. Rowy Run is likely out of memory. Please allocate more in GCP console.",
|
||||
{
|
||||
variant: "warning",
|
||||
persist: true,
|
||||
action: (snackbarId) => (
|
||||
<Button
|
||||
href={`https://console.cloud.google.com/run/deploy/${
|
||||
projectSettings.rowyRunRegion ?? "us-central1"
|
||||
}/rowy-backend?project=${projectId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => closeSnackbar(snackbarId)}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
>
|
||||
Open GCP Console <InlineOpenInNewIcon />
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
enqueueSnackbar(`Failed: ${error}`, { variant: "error" });
|
||||
}
|
||||
}
|
||||
};
|
||||
const isEmpty =
|
||||
|
||||
@@ -65,16 +65,28 @@ export default function Settings({
|
||||
: config.derivativeFn
|
||||
? config.derivativeFn
|
||||
: config?.script
|
||||
? `const derivative:Derivative = async ({row,ref,db,storage,auth})=>{
|
||||
${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")}
|
||||
}`
|
||||
: `const derivative:Derivative = async ({row,ref,db,storage,auth})=>{
|
||||
// Write your derivative code here
|
||||
// for example:
|
||||
// const sum = row.a + row.b;
|
||||
// return sum;
|
||||
// checkout the documentation for more info: https://docs.rowy.io/field-types/derivative
|
||||
}`;
|
||||
? `const derivative:Derivative = async ({row,ref,db,storage,auth,logging})=>{
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("derivative started")
|
||||
|
||||
// Import any NPM package needed
|
||||
// const lodash = require('lodash');
|
||||
|
||||
${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")}
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`
|
||||
: `const derivative:Derivative = async ({row,ref,db,storage,auth,logging})=>{
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("derivative started")
|
||||
|
||||
// Import any NPM package needed
|
||||
// const lodash = require('lodash');
|
||||
|
||||
// Example:
|
||||
// const sum = row.a + row.b;
|
||||
// return sum;
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -5,6 +5,7 @@ type DerivativeContext = {
|
||||
db: FirebaseFirestore.Firestore;
|
||||
auth: firebaseauth.BaseAuth;
|
||||
change: any;
|
||||
logging: RowyLogging;
|
||||
};
|
||||
|
||||
type Derivative = (context: DerivativeContext) => "PLACEHOLDER_OUTPUT_TYPE";
|
||||
|
||||
@@ -21,5 +21,6 @@ export const config: IFieldConfig = {
|
||||
settings: Settings,
|
||||
settingsValidator,
|
||||
requireConfiguration: true,
|
||||
requireCloudFunction: true,
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -2,27 +2,31 @@ import { ISettingsProps } from "@src/components/fields/types";
|
||||
import { TextField, Button } from "@mui/material";
|
||||
|
||||
export default function Settings({ onChange, config }: ISettingsProps) {
|
||||
|
||||
const copyStandardRegex = () => {
|
||||
onChange("validationRegex")("^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+.[a-zA-z]{2,3}$");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
type="text"
|
||||
label="Validation regex"
|
||||
id="validation-regex"
|
||||
value={config.validationRegex}
|
||||
fullWidth
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "") onChange("validationRegex")(null);
|
||||
else onChange("validationRegex")(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button style={{ width: "200px", margin: "20px auto auto" }} onClick={copyStandardRegex}>
|
||||
Use standard regex
|
||||
</Button>
|
||||
</>
|
||||
const copyStandardRegex = () => {
|
||||
onChange("validationRegex")(
|
||||
"^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+.[a-zA-z]{2,3}$"
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
type="text"
|
||||
label="Validation regex"
|
||||
id="validation-regex"
|
||||
value={config.validationRegex}
|
||||
fullWidth
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "") onChange("validationRegex")(null);
|
||||
else onChange("validationRegex")(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
style={{ width: "200px", margin: "20px auto auto" }}
|
||||
onClick={copyStandardRegex}
|
||||
>
|
||||
Use standard regex
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useCallback } from "react";
|
||||
import { IEditorCellProps } from "@src/components/fields/types";
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
@@ -15,6 +14,15 @@ import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import useFileUpload from "./useFileUpload";
|
||||
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
DropResult,
|
||||
ResponderProvided,
|
||||
} from "react-beautiful-dnd";
|
||||
|
||||
export default function File_({
|
||||
column,
|
||||
value,
|
||||
@@ -25,11 +33,40 @@ export default function File_({
|
||||
}: IEditorCellProps) {
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
|
||||
const { loading, progress, handleDelete, localFiles, dropzoneState } =
|
||||
useFileUpload(_rowy_ref, column.key, { multiple: true });
|
||||
const {
|
||||
loading,
|
||||
progress,
|
||||
handleDelete,
|
||||
localFiles,
|
||||
dropzoneState,
|
||||
handleUpdate,
|
||||
} = useFileUpload(_rowy_ref, column.key, { multiple: true });
|
||||
|
||||
const { isDragActive, getRootProps, getInputProps } = dropzoneState;
|
||||
const dropzoneProps = getRootProps();
|
||||
|
||||
const onDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||
const { destination, source } = result;
|
||||
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
destination.droppableId === source.droppableId &&
|
||||
destination.index === source.index
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = Array.from(value);
|
||||
|
||||
newValue.splice(source.index, 1);
|
||||
newValue.splice(destination.index, 0, value[source.index]);
|
||||
|
||||
handleUpdate([...newValue]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -37,6 +74,8 @@ export default function File_({
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
py: 0,
|
||||
pl: 1,
|
||||
|
||||
...(isDragActive
|
||||
? {
|
||||
@@ -54,68 +93,110 @@ export default function File_({
|
||||
tabIndex={tabIndex}
|
||||
onClick={undefined}
|
||||
>
|
||||
<ChipList rowHeight={rowHeight}>
|
||||
{Array.isArray(value) &&
|
||||
value.map((file: FileValue) => (
|
||||
<Grid
|
||||
item
|
||||
key={file.downloadURL}
|
||||
style={
|
||||
// Truncate so multiple files still visible
|
||||
value.length > 1 ? { maxWidth: `calc(100% - 12px)` } : {}
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
title={`File last modified ${format(
|
||||
file.lastModifiedTS,
|
||||
DATE_TIME_FORMAT
|
||||
)}`}
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="image-droppable" direction="horizontal">
|
||||
{(provided) => (
|
||||
<ChipList rowHeight={rowHeight}>
|
||||
<Grid
|
||||
container
|
||||
spacing={0.5}
|
||||
wrap="nowrap"
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
<Chip
|
||||
label={file.name}
|
||||
icon={<FileIcon />}
|
||||
sx={{
|
||||
"& .MuiChip-label": {
|
||||
lineHeight: 5 / 3,
|
||||
},
|
||||
}}
|
||||
onClick={(e: any) => e.stopPropagation()}
|
||||
component="a"
|
||||
href={file.downloadURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
clickable
|
||||
onDelete={
|
||||
disabled
|
||||
? undefined
|
||||
: () =>
|
||||
confirm({
|
||||
handleConfirm: () => handleDelete(file),
|
||||
title: "Delete file?",
|
||||
body: "This file cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
})
|
||||
}
|
||||
tabIndex={tabIndex}
|
||||
style={{ width: "100%", cursor: "pointer" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
))}
|
||||
{localFiles &&
|
||||
localFiles.map((file) => (
|
||||
<Grid item key={file.name}>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
deleteIcon={
|
||||
<CircularProgressOptical size={20} color="inherit" />
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</ChipList>
|
||||
{Array.isArray(value) &&
|
||||
value.map((file: FileValue, i) => (
|
||||
<Draggable
|
||||
key={file.downloadURL}
|
||||
draggableId={file.downloadURL}
|
||||
index={i}
|
||||
>
|
||||
{(provided) => (
|
||||
<Grid
|
||||
item
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
// Truncate so multiple files still visible
|
||||
maxWidth: `${
|
||||
value.length > 1 ? "calc(100% - 12px)" : "initial"
|
||||
}`,
|
||||
...provided.draggableProps.style,
|
||||
}}
|
||||
>
|
||||
{value.length > 1 && (
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon />
|
||||
</div>
|
||||
)}
|
||||
<Tooltip
|
||||
title={`File last modified ${format(
|
||||
file.lastModifiedTS,
|
||||
DATE_TIME_FORMAT
|
||||
)}`}
|
||||
>
|
||||
<Chip
|
||||
label={file.name}
|
||||
icon={<FileIcon />}
|
||||
sx={{
|
||||
"& .MuiChip-label": {
|
||||
lineHeight: 5 / 3,
|
||||
},
|
||||
}}
|
||||
onClick={(e: any) => e.stopPropagation()}
|
||||
component="a"
|
||||
href={file.downloadURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
clickable
|
||||
onDelete={
|
||||
disabled
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.preventDefault();
|
||||
confirm({
|
||||
handleConfirm: () => handleDelete(file),
|
||||
title: "Delete file?",
|
||||
body: "This file cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
tabIndex={tabIndex}
|
||||
style={{ width: "100%", cursor: "pointer" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{localFiles &&
|
||||
localFiles.map((file) => (
|
||||
<Grid item key={file.name}>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
deleteIcon={
|
||||
<CircularProgressOptical size={20} color="inherit" />
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</ChipList>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
|
||||
{!loading ? (
|
||||
!disabled && (
|
||||
|
||||
@@ -20,6 +20,15 @@ import { FileValue } from "@src/types/table";
|
||||
import useFileUpload from "./useFileUpload";
|
||||
import { FileIcon } from ".";
|
||||
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
DropResult,
|
||||
ResponderProvided,
|
||||
} from "react-beautiful-dnd";
|
||||
|
||||
export default function File_({
|
||||
column,
|
||||
_rowy_ref,
|
||||
@@ -72,52 +81,94 @@ export default function File_({
|
||||
</ButtonBase>
|
||||
)}
|
||||
|
||||
<Grid container spacing={0.5} style={{ marginTop: 2 }}>
|
||||
{Array.isArray(value) &&
|
||||
value.map((file: FileValue) => (
|
||||
<Grid item key={file.name}>
|
||||
<Tooltip
|
||||
title={`File last modified ${format(
|
||||
file.lastModifiedTS,
|
||||
DATE_TIME_FORMAT
|
||||
)}`}
|
||||
>
|
||||
<div>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
onClick={() => window.open(file.downloadURL)}
|
||||
onDelete={
|
||||
!disabled
|
||||
? () =>
|
||||
confirm({
|
||||
title: "Delete file?",
|
||||
body: "This file cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: () => handleDelete(file),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
))}
|
||||
<DragDropContext onDragEnd={() => console.log("onDragEnd")}>
|
||||
<Droppable droppableId="sidebar-file-droppable">
|
||||
{(provided) => (
|
||||
<Grid
|
||||
container
|
||||
spacing={0.5}
|
||||
style={{ marginTop: 2 }}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{Array.isArray(value) &&
|
||||
value.map((file: FileValue, i) => (
|
||||
<Draggable
|
||||
key={file.downloadURL}
|
||||
draggableId={file.downloadURL}
|
||||
index={i}
|
||||
>
|
||||
{(provided) => (
|
||||
<Grid
|
||||
item
|
||||
key={file.name}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
...provided.draggableProps.style,
|
||||
}}
|
||||
>
|
||||
{value.length > 1 && (
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon />
|
||||
</div>
|
||||
)}
|
||||
<Tooltip
|
||||
title={`File last modified ${format(
|
||||
file.lastModifiedTS,
|
||||
DATE_TIME_FORMAT
|
||||
)}`}
|
||||
>
|
||||
<div>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
onClick={() => window.open(file.downloadURL)}
|
||||
onDelete={
|
||||
!disabled
|
||||
? () =>
|
||||
confirm({
|
||||
title: "Delete file?",
|
||||
body: "This file cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: () => handleDelete(file),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
|
||||
{localFiles &&
|
||||
localFiles.map((file) => (
|
||||
<Grid item>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
deleteIcon={
|
||||
<CircularProgressOptical size={20} color="inherit" />
|
||||
}
|
||||
/>
|
||||
{localFiles &&
|
||||
localFiles.map((file) => (
|
||||
<Grid item>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
deleteIcon={
|
||||
<CircularProgressOptical size={20} color="inherit" />
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,15 @@ export default function useFileUpload(
|
||||
[deleteUpload, docRef, fieldName, updateField]
|
||||
);
|
||||
|
||||
// Drag and Drop
|
||||
const handleUpdate = (files: any) => {
|
||||
updateField({
|
||||
path: docRef.path,
|
||||
fieldName,
|
||||
value: files,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
localFiles,
|
||||
progress,
|
||||
@@ -83,5 +92,6 @@ export default function useFileUpload(
|
||||
handleUpload,
|
||||
handleDelete,
|
||||
dropzoneState,
|
||||
handleUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
27
src/components/fields/Formula/DisplayCell.tsx
Normal file
27
src/components/fields/Formula/DisplayCell.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
import { IDisplayCellProps } from "@src/components/fields/types";
|
||||
|
||||
import { useFormula } from "./useFormula";
|
||||
import { defaultFn, getDisplayCell } from "./util";
|
||||
|
||||
export default function Formula(props: IDisplayCellProps) {
|
||||
const { result, error, loading } = useFormula({
|
||||
row: props.row,
|
||||
ref: props._rowy_ref,
|
||||
listenerFields: props.column.config?.listenerFields || [],
|
||||
formulaFn: props.column.config?.formulaFn || defaultFn,
|
||||
});
|
||||
|
||||
const type = props.column.config?.renderFieldType;
|
||||
const DisplayCell = getDisplayCell(type);
|
||||
|
||||
if (error) {
|
||||
return <>Error: {error.message}</>;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <CircularProgressOptical id="progress" size={20} sx={{ m: 0.25 }} />;
|
||||
}
|
||||
|
||||
return <DisplayCell {...props} value={result} disabled={true} />;
|
||||
}
|
||||
75
src/components/fields/Formula/PreviewTable.tsx
Normal file
75
src/components/fields/Formula/PreviewTable.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Provider, useAtom } from "jotai";
|
||||
|
||||
import { currentUserAtom } from "@src/atoms/projectScope";
|
||||
import {
|
||||
tableRowsDbAtom,
|
||||
tableScope,
|
||||
tableSettingsAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
|
||||
import TablePage from "@src/pages/Table/TablePage";
|
||||
import { TableSchema } from "@src/types/table";
|
||||
import { Box, InputLabel } from "@mui/material";
|
||||
import TableSourcePreview from "./TableSourcePreview";
|
||||
|
||||
const PreviewTable = ({ tableSchema }: { tableSchema: TableSchema }) => {
|
||||
const [currentUser] = useAtom(currentUserAtom, tableScope);
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
return (
|
||||
<Box>
|
||||
<InputLabel>Preview table</InputLabel>
|
||||
<Provider
|
||||
key={"preview-table"}
|
||||
scope={tableScope}
|
||||
initialValues={[
|
||||
[currentUserAtom, currentUser],
|
||||
[tableSettingsAtom, tableSettings],
|
||||
[tableRowsDbAtom, []],
|
||||
]}
|
||||
>
|
||||
<TableSourcePreview tableSchema={tableSchema} />
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 300,
|
||||
overflow: "auto",
|
||||
marginTop: 1,
|
||||
marginLeft: 0,
|
||||
|
||||
// table toolbar
|
||||
"& > div:first-child": {
|
||||
display: "none",
|
||||
},
|
||||
// table grid
|
||||
"& > div:nth-of-type(2)": {
|
||||
height: "unset",
|
||||
},
|
||||
// emtpy state
|
||||
"& .empty-state": {
|
||||
display: "none",
|
||||
},
|
||||
// column actions - add column
|
||||
'& [data-col-id="_rowy_column_actions"]': {
|
||||
display: "none",
|
||||
},
|
||||
// row headers - sort by, column settings
|
||||
'& [data-row-id="_rowy_header"] > button': {
|
||||
display: "none",
|
||||
},
|
||||
// row headers - drag handler
|
||||
'& [data-row-id="_rowy_header"] > .column-drag-handle': {
|
||||
display: "none !important",
|
||||
},
|
||||
// row headers - resize handler
|
||||
'& [data-row-id="_rowy_header"] >:last-child': {
|
||||
display: "none !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TablePage disableModals={true} disableSideDrawer={true} />
|
||||
</Box>
|
||||
</Provider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewTable;
|
||||
169
src/components/fields/Formula/Settings.tsx
Normal file
169
src/components/fields/Formula/Settings.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { useAtom } from "jotai";
|
||||
import MultiSelect from "@rowy/multiselect";
|
||||
|
||||
import { Grid, InputLabel, Stack, FormHelperText } from "@mui/material";
|
||||
|
||||
import {
|
||||
tableColumnsOrderedAtom,
|
||||
tableSchemaAtom,
|
||||
tableScope,
|
||||
} from "@src/atoms/tableScope";
|
||||
|
||||
import FieldSkeleton from "@src/components/SideDrawer/FieldSkeleton";
|
||||
import { ISettingsProps } from "@src/components/fields/types";
|
||||
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
|
||||
import { DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT } from "@src/components/Table";
|
||||
import { ColumnConfig } from "@src/types/table";
|
||||
|
||||
import { defaultFn, listenerFieldTypes, outputFieldTypes } from "./util";
|
||||
import PreviewTable from "./PreviewTable";
|
||||
import { getFieldProp } from "..";
|
||||
|
||||
/* eslint-disable import/no-webpack-loader-syntax */
|
||||
import formulaDefs from "!!raw-loader!./formula.d.ts";
|
||||
import { WIKI_LINKS } from "@src/constants/externalLinks";
|
||||
import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper";
|
||||
|
||||
const CodeEditor = lazy(
|
||||
() =>
|
||||
import("@src/components/CodeEditor" /* webpackChunkName: "CodeEditor" */)
|
||||
);
|
||||
|
||||
const diagnosticsOptions = {
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
noSuggestionDiagnostics: true,
|
||||
};
|
||||
|
||||
export default function Settings({
|
||||
config,
|
||||
fieldName,
|
||||
onChange,
|
||||
onBlur,
|
||||
errors,
|
||||
}: ISettingsProps) {
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
|
||||
const returnType = getFieldProp("dataType", config.renderFieldType) ?? "any";
|
||||
const formulaFn = config?.formulaFn ? config.formulaFn : defaultFn;
|
||||
|
||||
const previewTableSchema = useMemo(() => {
|
||||
const columns = tableSchema.columns || {};
|
||||
return {
|
||||
...tableSchema,
|
||||
columns: Object.keys(columns).reduce((previewSchema, key) => {
|
||||
if ((config.listenerFields || []).includes(columns[key].fieldName)) {
|
||||
previewSchema[key] = {
|
||||
...columns[key],
|
||||
fixed: false,
|
||||
width: DEFAULT_COL_WIDTH,
|
||||
};
|
||||
}
|
||||
if (columns[key].fieldName === fieldName) {
|
||||
previewSchema[key] = {
|
||||
...columns[key],
|
||||
config,
|
||||
fixed: true,
|
||||
};
|
||||
}
|
||||
return previewSchema;
|
||||
}, {} as { [key: string]: ColumnConfig }),
|
||||
rowHeight: DEFAULT_ROW_HEIGHT,
|
||||
};
|
||||
}, [config, fieldName, tableSchema]);
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Grid container direction="row" spacing={2} flexWrap="nowrap">
|
||||
<Grid item xs={12} md={6}>
|
||||
<MultiSelect
|
||||
label="Listener fields"
|
||||
options={tableColumnsOrdered
|
||||
.filter((c) => listenerFieldTypes.includes(c.type))
|
||||
.map((c) => ({ label: c.name, value: c.key }))}
|
||||
value={config.listenerFields ?? []}
|
||||
onChange={onChange("listenerFields")}
|
||||
TextFieldProps={{
|
||||
helperText: (
|
||||
<>
|
||||
{errors.listenerFields && (
|
||||
<FormHelperText error style={{ margin: 0 }}>
|
||||
{errors.listenerFields}
|
||||
</FormHelperText>
|
||||
)}
|
||||
<FormHelperText error={false} style={{ margin: 0 }}>
|
||||
Changes to these fields will trigger the evaluation of the
|
||||
column.
|
||||
</FormHelperText>
|
||||
</>
|
||||
),
|
||||
FormHelperTextProps: { component: "div" } as any,
|
||||
required: true,
|
||||
error: errors.listenerFields,
|
||||
onBlur,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<FieldsDropdown
|
||||
label="Output field type"
|
||||
value={config.renderFieldType}
|
||||
options={outputFieldTypes}
|
||||
onChange={(value) => {
|
||||
onChange("renderFieldType")(value);
|
||||
}}
|
||||
TextFieldProps={{
|
||||
required: true,
|
||||
error: errors.renderFieldType,
|
||||
helperText: errors.renderFieldType,
|
||||
onBlur,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<InputLabel>Formula script</InputLabel>
|
||||
<div>
|
||||
<CodeEditorHelper
|
||||
disableDefaultVariables
|
||||
disableSecretManagerLink
|
||||
disableCloudManagerLink
|
||||
docLink={WIKI_LINKS.fieldTypesFormula}
|
||||
additionalVariables={[
|
||||
{
|
||||
key: "row",
|
||||
description: `row has the value of doc.data() it has type definitions using this table's schema, but you can only access formula's listener fields.`,
|
||||
},
|
||||
{
|
||||
key: "ref",
|
||||
description: `reference object that holds the readonly reference of the row document.(i.e ref.id)`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Suspense fallback={<FieldSkeleton height={200} />}>
|
||||
<CodeEditor
|
||||
diagnosticsOptions={diagnosticsOptions}
|
||||
value={formulaFn}
|
||||
extraLibs={[
|
||||
formulaDefs.replace(
|
||||
`"PLACEHOLDER_OUTPUT_TYPE"`,
|
||||
`${returnType} | Promise<${returnType}>`
|
||||
),
|
||||
]}
|
||||
onChange={useDebouncedCallback(onChange("formulaFn"), 300)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<PreviewTable tableSchema={previewTableSchema} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const settingsValidator = (config: any) => {
|
||||
const errors: Record<string, any> = {};
|
||||
if (config.error) errors.error = config.error;
|
||||
return errors;
|
||||
};
|
||||
83
src/components/fields/Formula/TableSourcePreview.ts
Normal file
83
src/components/fields/Formula/TableSourcePreview.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useAtomCallback } from "jotai/utils";
|
||||
import { cloneDeep, findIndex, sortBy } from "lodash-es";
|
||||
|
||||
import {
|
||||
_deleteRowDbAtom,
|
||||
_updateRowDbAtom,
|
||||
tableNextPageAtom,
|
||||
tableRowsDbAtom,
|
||||
tableSchemaAtom,
|
||||
tableScope,
|
||||
tableSettingsAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
|
||||
import { TableRow, TableSchema } from "@src/types/table";
|
||||
import { updateRowData } from "@src/utils/table";
|
||||
import { serializeRef } from "./util";
|
||||
|
||||
const TableSourcePreview = ({ tableSchema }: { tableSchema: TableSchema }) => {
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
const setTableSchemaAtom = useSetAtom(tableSchemaAtom, tableScope);
|
||||
const setRows = useSetAtom(tableRowsDbAtom, tableScope);
|
||||
useEffect(() => {
|
||||
setRows(
|
||||
["preview-doc-1", "preview-doc-2", "preview-doc-3"].map((docId) => ({
|
||||
_rowy_ref: serializeRef(`${tableSettings.collection}/${docId}`),
|
||||
}))
|
||||
);
|
||||
}, [setRows, tableSettings.collection]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableSchemaAtom(() => ({
|
||||
...tableSchema,
|
||||
_rowy_ref: "preview",
|
||||
}));
|
||||
}, [tableSchema, setTableSchemaAtom]);
|
||||
|
||||
const readRowsDb = useAtomCallback(
|
||||
useCallback((get) => get(tableRowsDbAtom) || [], []),
|
||||
tableScope
|
||||
);
|
||||
|
||||
const setUpdateRowDb = useSetAtom(_updateRowDbAtom, tableScope);
|
||||
setUpdateRowDb(() => async (path: string, update: Partial<TableRow>) => {
|
||||
const rows = await readRowsDb();
|
||||
const index = findIndex(rows, ["_rowy_ref.path", path]);
|
||||
if (index === -1) {
|
||||
setRows(
|
||||
sortBy(
|
||||
[
|
||||
...rows,
|
||||
{ ...update, _rowy_ref: { id: path.split("/").pop()!, path } },
|
||||
],
|
||||
["_rowy_ref.id"]
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const updatedRows = [...rows];
|
||||
updatedRows[index] = cloneDeep(rows[index]);
|
||||
updatedRows[index] = updateRowData(updatedRows[index], update);
|
||||
setRows(updatedRows);
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const setDeleteRowDb = useSetAtom(_deleteRowDbAtom, tableScope);
|
||||
setDeleteRowDb(() => async (path: string) => {
|
||||
const rows = await readRowsDb();
|
||||
const index = findIndex(rows, ["_rowy_ref.path", path]);
|
||||
if (index > -1) {
|
||||
setRows(rows.filter((_, idx) => idx !== index));
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const setNextPageAtom = useSetAtom(tableNextPageAtom, tableScope);
|
||||
setNextPageAtom({ loading: false, available: false });
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default TableSourcePreview;
|
||||
9
src/components/fields/Formula/formula.d.ts
vendored
Normal file
9
src/components/fields/Formula/formula.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
type RowRef<T> = { id: string; path: string; parent: T };
|
||||
interface Ref extends RowRef<Ref> {}
|
||||
|
||||
type FormulaContext = {
|
||||
row: Row;
|
||||
ref: Ref;
|
||||
};
|
||||
|
||||
type Formula = (context: FormulaContext) => "PLACEHOLDER_OUTPUT_TYPE";
|
||||
24
src/components/fields/Formula/index.tsx
Normal file
24
src/components/fields/Formula/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import FormulaIcon from "@mui/icons-material/Functions";
|
||||
import { IFieldConfig, FieldType } from "@src/components/fields/types";
|
||||
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
|
||||
import DisplayCell from "./DisplayCell";
|
||||
|
||||
import Settings, { settingsValidator } from "./Settings";
|
||||
|
||||
export const config: IFieldConfig = {
|
||||
type: FieldType.formula,
|
||||
name: "Formula",
|
||||
group: "Client Function",
|
||||
dataType: "any",
|
||||
initialValue: "",
|
||||
icon: <FormulaIcon />,
|
||||
description: "Client Function (Alpha)",
|
||||
TableCell: withRenderTableCell(DisplayCell as any, null, undefined, {
|
||||
usesRowData: true,
|
||||
}),
|
||||
SideDrawerField: () => null as any,
|
||||
settings: Settings,
|
||||
settingsValidator: settingsValidator,
|
||||
requireConfiguration: true,
|
||||
};
|
||||
export default config;
|
||||
82
src/components/fields/Formula/useFormula.tsx
Normal file
82
src/components/fields/Formula/useFormula.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { pick, zipObject } from "lodash-es";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import { TableRow, TableRowRef } from "@src/types/table";
|
||||
import { tableColumnsOrderedAtom, tableScope } from "@src/atoms/tableScope";
|
||||
|
||||
import {
|
||||
listenerFieldTypes,
|
||||
serializeRef,
|
||||
useDeepCompareMemoize,
|
||||
} from "./util";
|
||||
|
||||
export const useFormula = ({
|
||||
row,
|
||||
ref,
|
||||
listenerFields,
|
||||
formulaFn,
|
||||
}: {
|
||||
row: TableRow;
|
||||
ref: TableRowRef;
|
||||
listenerFields: string[];
|
||||
formulaFn: string;
|
||||
}) => {
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
|
||||
|
||||
const availableColumns = tableColumnsOrdered
|
||||
.filter((c) => listenerFieldTypes.includes(c.type))
|
||||
.map((c) => c.key);
|
||||
|
||||
const availableFields = useMemo(
|
||||
() => ({
|
||||
...zipObject(
|
||||
availableColumns,
|
||||
Array(availableColumns.length).fill(undefined)
|
||||
),
|
||||
...pick(row, availableColumns),
|
||||
}),
|
||||
[row, availableColumns]
|
||||
);
|
||||
|
||||
const listeners = useMemo(
|
||||
() => pick(availableFields, listenerFields),
|
||||
[availableFields, listenerFields]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
worker.onmessage = ({ data: { result, error } }: any) => {
|
||||
worker.terminate();
|
||||
if (error) {
|
||||
setError(error);
|
||||
} else {
|
||||
setResult(result);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
worker.postMessage(
|
||||
JSON.stringify({
|
||||
formulaFn,
|
||||
row: availableFields,
|
||||
ref: serializeRef(ref.path),
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
worker.terminate();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [useDeepCompareMemoize(listeners), formulaFn]);
|
||||
|
||||
return { result, error, loading };
|
||||
};
|
||||
144
src/components/fields/Formula/util.tsx
Normal file
144
src/components/fields/Formula/util.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { isEqual } from "lodash-es";
|
||||
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
|
||||
import ShortTextDisplayCell from "@src/components/fields/ShortText/DisplayCell";
|
||||
import LongTextDisplayCell from "@src/components/fields/LongText/DisplayCell";
|
||||
import RichTextDisplayCell from "@src/components/fields/RichText/DisplayCell";
|
||||
import UrlDisplayCell from "@src/components/fields/Url/DisplayCell";
|
||||
import NumberDisplayCell from "@src/components/fields/Number/DisplayCell";
|
||||
import CheckboxDisplayCell from "@src/components/fields/Checkbox/DisplayCell";
|
||||
import PercentageDisplayCell from "@src/components/fields/Percentage/DisplayCell";
|
||||
import RatingDisplayCell from "@src/components/fields/Rating/DisplayCell";
|
||||
import SliderDisplayCell from "@src/components/fields/Slider/DisplayCell";
|
||||
import SingleSelectDisplayCell from "@src/components/fields/SingleSelect/DisplayCell";
|
||||
import MultiSelectDisplayCell from "@src/components/fields/MultiSelect/DisplayCell";
|
||||
import ColorDisplayCell from "@src/components/fields/Color/DisplayCell";
|
||||
import GeoPointDisplayCell from "@src/components/fields/GeoPoint/DisplayCell";
|
||||
import DateDisplayCell from "@src/components/fields/Date/DisplayCell";
|
||||
import DateTimeDisplayCell from "@src/components/fields/DateTime/DisplayCell";
|
||||
import ImageDisplayCell from "@src/components/fields/Image/DisplayCell";
|
||||
import FileDisplayCell from "@src/components/fields/File/DisplayCell";
|
||||
import JsonDisplayCell from "@src/components/fields/Json/DisplayCell";
|
||||
import CodeDisplayCell from "@src/components/fields/Code/DisplayCell";
|
||||
import MarkdownDisplayCell from "@src/components/fields/Markdown/DisplayCell";
|
||||
import CreatedByDisplayCell from "@src/components/fields/CreatedBy/DisplayCell";
|
||||
import { TableRowRef } from "@src/types/table";
|
||||
import { DocumentData, DocumentReference } from "firebase/firestore";
|
||||
|
||||
export function useDeepCompareMemoize<T>(value: T) {
|
||||
const ref = useRef<T>(value);
|
||||
const signalRef = useRef<number>(0);
|
||||
|
||||
if (!isEqual(value, ref.current)) {
|
||||
ref.current = value;
|
||||
signalRef.current += 1;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return useMemo(() => ref.current, [signalRef.current]);
|
||||
}
|
||||
|
||||
export const listenerFieldTypes = Object.values(FieldType).filter(
|
||||
(type) =>
|
||||
![FieldType.formula, FieldType.subTable, FieldType.last].includes(type)
|
||||
);
|
||||
|
||||
export const outputFieldTypes = Object.values(FieldType).filter(
|
||||
(type) =>
|
||||
![
|
||||
FieldType.formula,
|
||||
FieldType.derivative,
|
||||
FieldType.action,
|
||||
FieldType.status,
|
||||
FieldType.aggregate,
|
||||
FieldType.connectService,
|
||||
FieldType.connectTable,
|
||||
FieldType.connector,
|
||||
FieldType.duration,
|
||||
FieldType.subTable,
|
||||
FieldType.reference,
|
||||
FieldType.createdAt,
|
||||
FieldType.createdBy,
|
||||
FieldType.updatedAt,
|
||||
FieldType.updatedBy,
|
||||
FieldType.last,
|
||||
].includes(type)
|
||||
);
|
||||
|
||||
export const defaultFn = `const formula:Formula = async ({ row, ref })=> {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
// Example:
|
||||
// return row.a + row.b;
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}
|
||||
`;
|
||||
|
||||
export const getDisplayCell = (type: FieldType) => {
|
||||
switch (type) {
|
||||
case FieldType.longText:
|
||||
return LongTextDisplayCell;
|
||||
case FieldType.richText:
|
||||
return RichTextDisplayCell;
|
||||
case FieldType.url:
|
||||
return UrlDisplayCell;
|
||||
case FieldType.number:
|
||||
return NumberDisplayCell;
|
||||
case FieldType.checkbox:
|
||||
return CheckboxDisplayCell;
|
||||
case FieldType.percentage:
|
||||
return PercentageDisplayCell;
|
||||
case FieldType.rating:
|
||||
return RatingDisplayCell;
|
||||
case FieldType.slider:
|
||||
return SliderDisplayCell;
|
||||
case FieldType.singleSelect:
|
||||
return SingleSelectDisplayCell;
|
||||
case FieldType.multiSelect:
|
||||
return MultiSelectDisplayCell;
|
||||
case FieldType.color:
|
||||
return ColorDisplayCell;
|
||||
case FieldType.geoPoint:
|
||||
return GeoPointDisplayCell;
|
||||
case FieldType.date:
|
||||
return DateDisplayCell;
|
||||
case FieldType.dateTime:
|
||||
return DateTimeDisplayCell;
|
||||
case FieldType.image:
|
||||
return ImageDisplayCell;
|
||||
case FieldType.file:
|
||||
return FileDisplayCell;
|
||||
case FieldType.json:
|
||||
return JsonDisplayCell;
|
||||
case FieldType.code:
|
||||
return CodeDisplayCell;
|
||||
case FieldType.markdown:
|
||||
return MarkdownDisplayCell;
|
||||
case FieldType.createdBy:
|
||||
return CreatedByDisplayCell;
|
||||
default:
|
||||
return ShortTextDisplayCell;
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeRef = (path: string, maxDepth = 20) => {
|
||||
const pathArr = path.split("/");
|
||||
const serializedRef = {
|
||||
path: pathArr.join("/"),
|
||||
id: pathArr.pop(),
|
||||
} as any;
|
||||
let curr: TableRowRef | Partial<DocumentReference<DocumentData>> =
|
||||
serializedRef;
|
||||
let depth = 0;
|
||||
while (pathArr.length > 0 && curr && depth < maxDepth) {
|
||||
(curr.parent as any) = {
|
||||
path: pathArr.join("/"),
|
||||
id: pathArr.pop(),
|
||||
} as Partial<DocumentReference<DocumentData>>;
|
||||
curr = curr.parent as any;
|
||||
maxDepth++;
|
||||
}
|
||||
return serializedRef;
|
||||
};
|
||||
25
src/components/fields/Formula/worker.ts
Normal file
25
src/components/fields/Formula/worker.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
onmessage = async ({ data }) => {
|
||||
try {
|
||||
const { formulaFn, row, ref } = JSON.parse(data);
|
||||
const AsyncFunction = async function () {}.constructor as any;
|
||||
const [_, fnBody] = formulaFn.match(/=>\s*({?[\s\S]*}?)$/);
|
||||
if (!fnBody) return;
|
||||
const fn = new AsyncFunction(
|
||||
"row",
|
||||
"ref",
|
||||
`const fn = async () => \n${fnBody}\n return fn();`
|
||||
);
|
||||
const result = await fn(row, ref);
|
||||
postMessage({ result });
|
||||
} catch (error: any) {
|
||||
console.error("Error: ", error);
|
||||
postMessage({
|
||||
error,
|
||||
});
|
||||
} finally {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
self.close();
|
||||
}
|
||||
};
|
||||
|
||||
export {};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { IEditorCellProps } from "@src/components/fields/types";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { assignIn } from "lodash-es";
|
||||
|
||||
import { alpha, Box, Stack, Grid, IconButton, ButtonBase } from "@mui/material";
|
||||
@@ -11,13 +11,20 @@ import Thumbnail from "@src/components/Thumbnail";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
|
||||
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
|
||||
import { tableSchemaAtom, tableScope } from "@src/atoms/tableScope";
|
||||
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import useFileUpload from "@src/components/fields/File/useFileUpload";
|
||||
import { IMAGE_MIME_TYPES } from "./index";
|
||||
import { imgSx, thumbnailSx, deleteImgHoverSx } from "./DisplayCell";
|
||||
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
DropResult,
|
||||
ResponderProvided,
|
||||
} from "react-beautiful-dnd";
|
||||
|
||||
export default function Image_({
|
||||
column,
|
||||
value,
|
||||
@@ -28,11 +35,17 @@ export default function Image_({
|
||||
}: IEditorCellProps) {
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
|
||||
const { loading, progress, handleDelete, localFiles, dropzoneState } =
|
||||
useFileUpload(_rowy_ref, column.key, {
|
||||
multiple: true,
|
||||
accept: IMAGE_MIME_TYPES,
|
||||
});
|
||||
const {
|
||||
loading,
|
||||
progress,
|
||||
handleDelete,
|
||||
localFiles,
|
||||
dropzoneState,
|
||||
handleUpdate,
|
||||
} = useFileUpload(_rowy_ref, column.key, {
|
||||
multiple: true,
|
||||
accept: IMAGE_MIME_TYPES,
|
||||
});
|
||||
|
||||
const localImages = useMemo(
|
||||
() =>
|
||||
@@ -45,6 +58,28 @@ export default function Image_({
|
||||
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
|
||||
const dropzoneProps = getRootProps();
|
||||
|
||||
const onDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||
const { destination, source } = result;
|
||||
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
destination.droppableId === source.droppableId &&
|
||||
destination.index === source.index
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = Array.from(value);
|
||||
|
||||
newValue.splice(source.index, 1);
|
||||
newValue.splice(destination.index, 0, value[source.index]);
|
||||
|
||||
handleUpdate([...newValue]);
|
||||
};
|
||||
|
||||
let thumbnailSize = "100x100";
|
||||
if (rowHeight > 50) thumbnailSize = "200x200";
|
||||
if (rowHeight > 100) thumbnailSize = "400x400";
|
||||
@@ -84,62 +119,102 @@ export default function Image_({
|
||||
marginLeft: "0 !important",
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={0.5} wrap="nowrap">
|
||||
{Array.isArray(value) &&
|
||||
value.map((file: FileValue, i) => (
|
||||
<Grid item key={file.downloadURL}>
|
||||
<ButtonBase
|
||||
aria-label="Delete…"
|
||||
sx={imgSx(rowHeight)}
|
||||
className="img"
|
||||
onClick={() => {
|
||||
confirm({
|
||||
title: "Delete image?",
|
||||
body: "This image cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: () => handleDelete(file),
|
||||
});
|
||||
}}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<Thumbnail
|
||||
imageUrl={file.downloadURL}
|
||||
size={thumbnailSize}
|
||||
objectFit="contain"
|
||||
sx={thumbnailSx}
|
||||
/>
|
||||
<Grid
|
||||
container
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={deleteImgHoverSx}
|
||||
>
|
||||
<DeleteIcon color="error" />
|
||||
</Grid>
|
||||
</ButtonBase>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="image-droppable" direction="horizontal">
|
||||
{(provided) => (
|
||||
<Grid
|
||||
container
|
||||
spacing={0.5}
|
||||
wrap="nowrap"
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{Array.isArray(value) &&
|
||||
value.map((file: FileValue, i) => (
|
||||
<Draggable
|
||||
key={file.downloadURL}
|
||||
draggableId={file.downloadURL}
|
||||
index={i}
|
||||
>
|
||||
{(provided) => (
|
||||
<Grid
|
||||
item
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
...provided.draggableProps.style,
|
||||
}}
|
||||
>
|
||||
{value.length > 1 && (
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon />
|
||||
</div>
|
||||
)}
|
||||
<ButtonBase
|
||||
aria-label="Delete…"
|
||||
sx={imgSx(rowHeight)}
|
||||
className="img"
|
||||
onClick={() => {
|
||||
confirm({
|
||||
title: "Delete image?",
|
||||
body: "This image cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: () => handleDelete(file),
|
||||
});
|
||||
}}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<Thumbnail
|
||||
imageUrl={file.downloadURL}
|
||||
size={thumbnailSize}
|
||||
objectFit="contain"
|
||||
sx={thumbnailSx}
|
||||
/>
|
||||
<Grid
|
||||
container
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={deleteImgHoverSx}
|
||||
>
|
||||
<DeleteIcon color="error" />
|
||||
</Grid>
|
||||
</ButtonBase>
|
||||
</Grid>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{localImages &&
|
||||
localImages.map((image) => (
|
||||
<Grid item>
|
||||
<Box
|
||||
sx={[
|
||||
imgSx(rowHeight),
|
||||
{
|
||||
boxShadow: (theme) =>
|
||||
`0 0 0 1px ${theme.palette.divider} inset`,
|
||||
},
|
||||
]}
|
||||
style={{
|
||||
backgroundImage: `url("${image.localURL}")`,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
{localImages &&
|
||||
localImages.map((image) => (
|
||||
<Grid item>
|
||||
<Box
|
||||
sx={[
|
||||
imgSx(rowHeight),
|
||||
{
|
||||
boxShadow: (theme) =>
|
||||
`0 0 0 1px ${theme.palette.divider} inset`,
|
||||
},
|
||||
]}
|
||||
style={{
|
||||
backgroundImage: `url("${image.localURL}")`,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
|
||||
{!loading ? (
|
||||
|
||||
@@ -26,6 +26,15 @@ import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
|
||||
import useFileUpload from "@src/components/fields/File/useFileUpload";
|
||||
import { IMAGE_MIME_TYPES } from ".";
|
||||
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
DropResult,
|
||||
ResponderProvided,
|
||||
} from "react-beautiful-dnd";
|
||||
|
||||
const imgSx = {
|
||||
position: "relative",
|
||||
width: 80,
|
||||
@@ -94,6 +103,7 @@ export default function Image_({
|
||||
uploaderState,
|
||||
localFiles,
|
||||
dropzoneState,
|
||||
handleUpdate,
|
||||
} = useFileUpload(_rowy_ref, column.key, {
|
||||
multiple: true,
|
||||
accept: IMAGE_MIME_TYPES,
|
||||
@@ -109,6 +119,28 @@ export default function Image_({
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
|
||||
|
||||
const onDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||
const { destination, source } = result;
|
||||
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
destination.droppableId === source.droppableId &&
|
||||
destination.index === source.index
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = Array.from(value);
|
||||
|
||||
newValue.splice(source.index, 1);
|
||||
newValue.splice(destination.index, 0, value[source.index]);
|
||||
|
||||
handleUpdate([...newValue]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!disabled && (
|
||||
@@ -151,112 +183,158 @@ export default function Image_({
|
||||
</ButtonBase>
|
||||
)}
|
||||
|
||||
<Grid container spacing={1} style={{ marginTop: 0 }}>
|
||||
{Array.isArray(value) &&
|
||||
value.map((image: FileValue) => (
|
||||
<Grid item key={image.name}>
|
||||
{disabled ? (
|
||||
<Tooltip title="Open">
|
||||
<ButtonBase
|
||||
sx={imgSx}
|
||||
onClick={() => window.open(image.downloadURL, "_blank")}
|
||||
className="img"
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="sidebar-image-droppable" direction="horizontal">
|
||||
{(provided) => (
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
style={{ marginTop: 0 }}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{Array.isArray(value) &&
|
||||
value.map((image: FileValue, i) => (
|
||||
<Draggable
|
||||
key={image.downloadURL}
|
||||
draggableId={image.downloadURL}
|
||||
index={i}
|
||||
>
|
||||
<Thumbnail
|
||||
imageUrl={image.downloadURL}
|
||||
size="200x200"
|
||||
objectFit="contain"
|
||||
sx={thumbnailSx}
|
||||
/>
|
||||
<Grid
|
||||
container
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={[overlaySx, deleteImgHoverSx]}
|
||||
{(provided) => (
|
||||
<Grid item>
|
||||
{disabled ? (
|
||||
<Tooltip title="Open">
|
||||
<ButtonBase
|
||||
sx={imgSx}
|
||||
onClick={() =>
|
||||
window.open(image.downloadURL, "_blank")
|
||||
}
|
||||
className="img"
|
||||
>
|
||||
<Thumbnail
|
||||
imageUrl={image.downloadURL}
|
||||
size="200x200"
|
||||
objectFit="contain"
|
||||
sx={thumbnailSx}
|
||||
/>
|
||||
<Grid
|
||||
container
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={[overlaySx, deleteImgHoverSx]}
|
||||
>
|
||||
{disabled ? (
|
||||
<OpenIcon />
|
||||
) : (
|
||||
<DeleteIcon color="error" />
|
||||
)}
|
||||
</Grid>
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
...provided.draggableProps.style,
|
||||
}}
|
||||
>
|
||||
{value.length > 1 && (
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon />
|
||||
</div>
|
||||
)}
|
||||
<Box sx={imgSx} className="img">
|
||||
<Thumbnail
|
||||
imageUrl={image.downloadURL}
|
||||
size="200x200"
|
||||
objectFit="contain"
|
||||
sx={thumbnailSx}
|
||||
/>
|
||||
<Grid
|
||||
container
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={[overlaySx, deleteImgHoverSx]}
|
||||
>
|
||||
<Tooltip title="Delete…">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
confirm({
|
||||
title: "Delete image?",
|
||||
body: "This image cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: () =>
|
||||
handleDelete(image),
|
||||
})
|
||||
}
|
||||
>
|
||||
<DeleteIcon color="error" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Open">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
window.open(image.downloadURL, "_blank")
|
||||
}
|
||||
>
|
||||
<OpenIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{localImages &&
|
||||
localImages.map((image) => (
|
||||
<Grid item key={image.name}>
|
||||
<ButtonBase
|
||||
sx={imgSx}
|
||||
style={{
|
||||
backgroundImage: `url("${image.localURL}")`,
|
||||
}}
|
||||
className="img"
|
||||
>
|
||||
{disabled ? <OpenIcon /> : <DeleteIcon color="error" />}
|
||||
</Grid>
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div>
|
||||
<Box sx={imgSx} className="img">
|
||||
<Thumbnail
|
||||
imageUrl={image.downloadURL}
|
||||
size="200x200"
|
||||
objectFit="contain"
|
||||
sx={thumbnailSx}
|
||||
/>
|
||||
<Grid
|
||||
container
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={[overlaySx, deleteImgHoverSx]}
|
||||
>
|
||||
<Tooltip title="Delete…">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
confirm({
|
||||
title: "Delete image?",
|
||||
body: "This image cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: () => handleDelete(image),
|
||||
})
|
||||
}
|
||||
{uploaderState[image.name] && (
|
||||
<Grid
|
||||
container
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={overlaySx}
|
||||
>
|
||||
<DeleteIcon color="error" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Open">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
window.open(image.downloadURL, "_blank")
|
||||
}
|
||||
>
|
||||
<OpenIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
{localImages &&
|
||||
localImages.map((image) => (
|
||||
<Grid item key={image.name}>
|
||||
<ButtonBase
|
||||
sx={imgSx}
|
||||
style={{
|
||||
backgroundImage: `url("${image.localURL}")`,
|
||||
}}
|
||||
className="img"
|
||||
>
|
||||
{uploaderState[image.name] && (
|
||||
<Grid
|
||||
container
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={overlaySx}
|
||||
>
|
||||
<CircularProgressOptical
|
||||
color="inherit"
|
||||
size={48}
|
||||
variant={
|
||||
uploaderState[image.name].progress === 0
|
||||
? "indeterminate"
|
||||
: "determinate"
|
||||
}
|
||||
value={uploaderState[image.name].progress}
|
||||
/>
|
||||
<CircularProgressOptical
|
||||
color="inherit"
|
||||
size={48}
|
||||
variant={
|
||||
uploaderState[image.name].progress === 0
|
||||
? "indeterminate"
|
||||
: "determinate"
|
||||
}
|
||||
value={uploaderState[image.name].progress}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Grid>
|
||||
)}
|
||||
</ButtonBase>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IDisplayCellProps } from "@src/components/fields/types";
|
||||
|
||||
import { useTheme } from "@mui/material";
|
||||
import { resultColorsScale } from "@src/utils/color";
|
||||
import { multiply100WithPrecision } from "./utils";
|
||||
|
||||
export default function Percentage({ column, value }: IDisplayCellProps) {
|
||||
const theme = useTheme();
|
||||
@@ -34,7 +35,7 @@ export default function Percentage({ column, value }: IDisplayCellProps) {
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{Math.round(percentage * 100)}%
|
||||
{multiply100WithPrecision(percentage)}%
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { IEditorCellProps } from "@src/components/fields/types";
|
||||
import EditorCellTextField from "@src/components/Table/TableCell/EditorCellTextField";
|
||||
import { multiply100WithPrecision, divide100WithPrecision } from "./utils";
|
||||
|
||||
export default function Percentage(props: IEditorCellProps<number>) {
|
||||
return (
|
||||
<EditorCellTextField
|
||||
{...(props as any)}
|
||||
InputProps={{ type: "number", endAdornment: "%" }}
|
||||
value={typeof props.value === "number" ? props.value * 100 : props.value}
|
||||
onChange={(v) => props.onChange(Number(v) / 100)}
|
||||
value={
|
||||
typeof props.value === "number"
|
||||
? multiply100WithPrecision(props.value)
|
||||
: props.value
|
||||
}
|
||||
onChange={(v) => {
|
||||
props.onChange(divide100WithPrecision(Number(v)));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user