Merge branch 'develop' into ui-bug-fixes

This commit is contained in:
Shams
2023-05-10 17:04:57 +02:00
committed by GitHub
89 changed files with 2317 additions and 534 deletions

View File

@@ -17,11 +17,12 @@ Read the documentation on setting up your local development environment
Read how to submit a pull request [here](https://docs.rowy.io/contributing).
To get familiar with the project,
[good first issues](https://github.com/rowyio/rowy/projects/3) is a good place
[good first issues](https://github.com/rowyio/rowy/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) is a good place
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
@@ -38,6 +39,7 @@ 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.
## File a feature request
If you have some interesting idea that will be a good addition to Rowy, then
@@ -46,7 +48,7 @@ create a new issue using
to share your idea. If you are working on this to contribute to the project,
then let others in the community and project maintainers know by posting on
#contributions channel in Rowy's
[Discord](https://discord.com/invite/fjBugmvzZP). This allows others in the
[Discord](https://rowy.io/discord). This allows others in the
community and the maintainers a chance to provide feedback and guidance before
you spend time working on it.

View File

@@ -13,12 +13,12 @@ Low-code for Firebase and Google Cloud.
<div align="center">
[![Discord](https://img.shields.io/discord/853498675484819476?color=%234200FF&label=Chat%20with%20us&logo=discord&logoColor=%23FFFFFF&style=for-the-badge)](https://discord.gg/fjBugmvzZP)
[![Rowy Discord](https://dcbadge.vercel.app/api/server/fjBugmvzZP)](https://discord.gg/fjBugmvzZP)
<p align="center">
<a href="http://www.rowy.io"><b>Website</b></a> •
<a href="http://docs.rowy.io"><b>Documentation</b></a> •
<a href="https://discord.gg/fjBugmvzZP"><b>Discord</b></a> •
<a href="https://discord.gg/fjBugmvzZP"><b>Chat with us</b></a> •
<a href="https://twitter.com/rowyio"><b>Twitter</b></a>
</p>
@@ -27,11 +27,12 @@ Low-code for Firebase and Google Cloud.
</div>
## Live Demo
## Live Demo 🛝
💥 Check out the [live demo](https://demo.rowy.io/) of Rowy 💥
💥 Explore Rowy on [live demo playground](https://demo.rowy.io/) 💥
## Features ✨
## Features
<!-- <table>
<tr>
@@ -39,7 +40,7 @@ Low-code for Firebase and Google Cloud.
<a href="#">Database</a>
</th>
<th>
<a href="#">Code</a>
<a href="#">Automation</a>
</th>
</tr>
<tr>

View File

@@ -23,6 +23,7 @@ import useKeyPressWithAtom from "@src/hooks/useKeyPressWithAtom";
import TableGroupRedirectPage from "./pages/TableGroupRedirectPage";
import SignOutPage from "@src/pages/Auth/SignOutPage";
import ProvidedArraySubTablePage from "./pages/Table/ProvidedArraySubTablePage";
// prettier-ignore
const AuthPage = lazy(() => import("@src/pages/Auth/AuthPage" /* webpackChunkName: "AuthPage" */));
@@ -134,6 +135,27 @@ export default function App() {
}
/>
</Route>
<Route path={ROUTES.arraySubTable}>
<Route index element={<NotFound />} />
<Route
path=":docPath/:subTableKey"
element={
<Suspense
fallback={
<Backdrop
key="sub-table-modal-backdrop"
open
sx={{ zIndex: "modal" }}
>
<Loading />
</Backdrop>
}
>
<ProvidedArraySubTablePage />
</Suspense>
}
/>
</Route>
</Route>
</Route>

View File

@@ -0,0 +1,9 @@
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
export function ArraySubTable(props: SvgIconProps) {
return (
<SvgIcon {...props}>
<path d="M1 4C1 2.34315 2.34315 1 4 1H18C19.6569 1 21 2.34315 21 4V11H19H12V15V17H4C2.34315 17 1 15.6569 1 14V4ZM10 15V11H3V14C3 14.5523 3.44772 15 4 15H10ZM12 9H19V5H12V9ZM10 5H3V9H10V5ZM15 13H14V14V22V23H15H17V21H16V15H17V13H15ZM21 13H22V14V22V23H21H19V21H20V15H19V13H21Z" />
</SvgIcon>
);
}

View File

@@ -133,3 +133,16 @@ export const FunctionsIndexAtom = atom<FunctionSettings[]>([]);
export const updateFunctionAtom = atom<
UpdateCollectionDocFunction<FunctionSettings> | undefined
>(undefined);
export interface ISecretNames {
loading: boolean;
secretNames: null | string[];
}
export const secretNamesAtom = atom<ISecretNames>({
loading: true,
secretNames: null,
});
export const updateSecretNamesAtom = atom<
((clearSecretNames?: boolean) => Promise<void>) | undefined
>(undefined);

View File

@@ -494,7 +494,11 @@ describe("deleteRow", () => {
} = renderHook(() => useSetAtom(deleteRowAtom, tableScope));
expect(deleteRow).toBeDefined();
await act(() => deleteRow(TEST_COLLECTION + "/row2"));
await act(() =>
deleteRow({
path: TEST_COLLECTION + "/row2",
})
);
const {
result: { current: tableRows },
@@ -510,7 +514,11 @@ describe("deleteRow", () => {
} = renderHook(() => useSetAtom(deleteRowAtom, tableScope));
expect(deleteRow).toBeDefined();
await act(() => deleteRow(TEST_COLLECTION + "/rowLocal2"));
await act(() =>
deleteRow({
path: TEST_COLLECTION + "/rowLocal2",
})
);
const {
result: { current: tableRows },
@@ -527,9 +535,9 @@ describe("deleteRow", () => {
expect(deleteRow).toBeDefined();
await act(() =>
deleteRow(
["row1", "row2", "row8"].map((id) => TEST_COLLECTION + "/" + id)
)
deleteRow({
path: ["row1", "row2", "row8"].map((id) => TEST_COLLECTION + "/" + id),
})
);
const {
@@ -548,7 +556,11 @@ describe("deleteRow", () => {
} = renderHook(() => useSetAtom(deleteRowAtom, tableScope));
expect(deleteRow).toBeDefined();
await act(() => deleteRow(generatedRows.map((row) => row._rowy_ref.path)));
await act(() =>
deleteRow({
path: generatedRows.map((row) => row._rowy_ref.path),
})
);
const {
result: { current: tableRows },
@@ -563,7 +575,11 @@ describe("deleteRow", () => {
} = renderHook(() => useSetAtom(deleteRowAtom, tableScope));
expect(deleteRow).toBeDefined();
await act(() => deleteRow("nonExistent"));
await act(() =>
deleteRow({
path: "nonExistent",
})
);
const {
result: { current: tableRows },
@@ -578,7 +594,11 @@ describe("deleteRow", () => {
} = renderHook(() => useSetAtom(deleteRowAtom, tableScope));
expect(deleteRow).toBeDefined();
await act(() => deleteRow("nonExistent"));
await act(() =>
deleteRow({
path: "nonExistent",
})
);
const {
result: { current: tableRows },

View File

@@ -22,7 +22,11 @@ import {
_bulkWriteDbAtom,
} from "./table";
import { TableRow, BulkWriteFunction } from "@src/types/table";
import {
TableRow,
BulkWriteFunction,
ArrayTableRowData,
} from "@src/types/table";
import {
rowyUser,
generateId,
@@ -211,7 +215,17 @@ export const addRowAtom = atom(
*/
export const deleteRowAtom = atom(
null,
async (get, set, path: string | string[]) => {
async (
get,
set,
{
path,
options,
}: {
path: string | string[];
options?: ArrayTableRowData;
}
) => {
const deleteRowDb = get(_deleteRowDbAtom);
if (!deleteRowDb) throw new Error("Cannot write to database");
@@ -223,9 +237,9 @@ export const deleteRowAtom = atom(
find(tableRowsLocal, ["_rowy_ref.path", path])
);
if (isLocalRow) set(tableRowsLocalAtom, { type: "delete", path });
// Always delete from db in case it exists
await deleteRowDb(path);
// *options* are passed in case of array table to target specific row
await deleteRowDb(path, options);
if (auditChange) auditChange("DELETE_ROW", path);
};
@@ -321,6 +335,8 @@ export interface IUpdateFieldOptions {
useArrayUnion?: boolean;
/** Optionally, uses firestore's arrayRemove with the given value. Removes given value items from the existing array */
useArrayRemove?: boolean;
/** Optionally, used to locate the row in ArraySubTable. */
arrayTableData?: ArrayTableRowData;
}
/**
* Set function updates or deletes a field in a row.
@@ -348,6 +364,7 @@ export const updateFieldAtom = atom(
disableCheckEquality,
useArrayUnion,
useArrayRemove,
arrayTableData,
}: IUpdateFieldOptions
) => {
const updateRowDb = get(_updateRowDbAtom);
@@ -361,9 +378,17 @@ export const updateFieldAtom = atom(
const tableRows = get(tableRowsAtom);
const tableRowsLocal = get(tableRowsLocalAtom);
const row = find(tableRows, ["_rowy_ref.path", path]);
const row = find(
tableRows,
arrayTableData?.index !== undefined
? ["_rowy_ref.arrayTableData.index", arrayTableData?.index]
: ["_rowy_ref.path", path]
);
if (!row) throw new Error("Could not find row");
const isLocalRow = Boolean(find(tableRowsLocal, ["_rowy_ref.path", path]));
const isLocalRow =
fieldName.startsWith("_rowy_formulaValue_") ||
Boolean(find(tableRowsLocal, ["_rowy_ref.path", path]));
const update: Partial<TableRow> = {};
@@ -396,7 +421,12 @@ export const updateFieldAtom = atom(
...(row[fieldName] ?? []),
...localUpdate[fieldName],
];
dbUpdate[fieldName] = arrayUnion(...dbUpdate[fieldName]);
// if we are updating a row of ArraySubTable
if (arrayTableData?.index !== undefined) {
dbUpdate[fieldName] = localUpdate[fieldName];
} else {
dbUpdate[fieldName] = arrayUnion(...dbUpdate[fieldName]);
}
}
//apply arrayRemove
@@ -409,8 +439,15 @@ export const updateFieldAtom = atom(
row[fieldName] ?? [],
(el) => !find(localUpdate[fieldName], el)
);
dbUpdate[fieldName] = arrayRemove(...dbUpdate[fieldName]);
// if we are updating a row of ArraySubTable
if (arrayTableData?.index !== undefined) {
dbUpdate[fieldName] = localUpdate[fieldName];
} else {
dbUpdate[fieldName] = arrayRemove(...dbUpdate[fieldName]);
}
}
// need to pass the index of the row to updateRowDb
// Check for required fields
const newRowValues = updateRowData(cloneDeep(row), dbUpdate);
@@ -432,6 +469,14 @@ export const updateFieldAtom = atom(
deleteFields: deleteField ? [fieldName] : [],
});
// TODO(han): Formula field persistence
// const config = find(tableColumnsOrdered, (c) => {
// const [, key] = fieldName.split("_rowy_formulaValue_");
// return c.key === key;
// });
// if(!config.persist) return;
if (fieldName.startsWith("_rowy_formulaValue")) return;
// If it has no missingRequiredFields, also write to db
// And write entire row to handle the case where it doesnt exist in db yet
if (missingRequiredFields.length === 0) {
@@ -440,7 +485,8 @@ export const updateFieldAtom = atom(
await updateRowDb(
row._rowy_ref.path,
omitRowyFields(newRowValues),
deleteField ? [fieldName] : []
deleteField ? [fieldName] : [],
arrayTableData
);
}
}
@@ -449,7 +495,8 @@ export const updateFieldAtom = atom(
await updateRowDb(
row._rowy_ref.path,
omitRowyFields(dbUpdate),
deleteField ? [fieldName] : []
deleteField ? [fieldName] : [],
arrayTableData
);
}

View File

@@ -134,6 +134,7 @@ export type SelectedCell = {
path: string | "_rowy_header";
columnKey: string | "_rowy_row_actions";
focusInside: boolean;
arrayIndex?: number; // for array sub table
};
/** Store selected cell in table. Used in side drawer and context menu */
export const selectedCellAtom = atom<SelectedCell | null>(null);

View File

@@ -19,8 +19,7 @@ import firebaseStorageDefs from "!!raw-loader!./firebaseStorage.d.ts";
import utilsDefs from "!!raw-loader!./utils.d.ts";
import rowyUtilsDefs from "!!raw-loader!./rowy.d.ts";
import extensionsDefs from "!!raw-loader!./extensions.d.ts";
import { runRoutes } from "@src/constants/runRoutes";
import { rowyRunAtom, projectScope } from "@src/atoms/projectScope";
import { projectScope, secretNamesAtom } from "@src/atoms/projectScope";
import { getFieldProp } from "@src/components/fields";
export interface IUseMonacoCustomizationsProps {
@@ -53,8 +52,8 @@ export default function useMonacoCustomizations({
const theme = useTheme();
const monaco = useMonaco();
const [tableRows] = useAtom(tableRowsAtom, tableScope);
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const [secretNames] = useAtom(secretNamesAtom, projectScope);
useEffect(() => {
return () => {
@@ -206,26 +205,6 @@ export default function useMonacoCustomizations({
//}
};
const setSecrets = async () => {
// set secret options
try {
const listSecrets = await rowyRun({
route: runRoutes.listSecrets,
});
const secretsDef = `type SecretNames = ${listSecrets
.map((secret: string) => `"${secret}"`)
.join(" | ")}
enum secrets {
${listSecrets
.map((secret: string) => `${secret} = "${secret}"`)
.join("\n")}
}
`;
monaco?.languages.typescript.javascriptDefaults.addExtraLib(secretsDef);
} catch (error) {
console.error("Could not set secret definitions: ", error);
}
};
//TODO: types
const setBaseDefinitions = () => {
const rowDefinition =
@@ -275,14 +254,24 @@ export default function useMonacoCustomizations({
} catch (error) {
console.error("Could not set basic", error);
}
// set available secrets from secretManager
try {
setSecrets();
} catch (error) {
console.error("Could not set secrets: ", error);
}
}, [monaco, tableColumnsOrdered]);
useEffect(() => {
if (!monaco) return;
if (secretNames.loading) return;
if (!secretNames.secretNames) return;
const secretsDef = `type SecretNames = ${secretNames.secretNames
.map((secret: string) => `"${secret}"`)
.join(" | ")}
enum secrets {
${secretNames.secretNames
.map((secret: string) => `${secret} = "${secret}"`)
.join("\n")}
}
`;
monaco?.languages.typescript.javascriptDefaults.addExtraLib(secretsDef);
}, [monaco, secretNames]);
let boxSx: SystemStyleObject<Theme> = {
minWidth: 400,
minHeight,

View File

@@ -54,22 +54,24 @@ function CodeEditor({ type, column, handleChange }: ICodeEditorProps) {
dynamicValueFn = column.config?.defaultValue?.dynamicValueFn;
} else if (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,logging})=>{
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("dynamicValueFn started")
dynamicValueFn = `// Import any NPM package needed
// import _ from "lodash";
const defaultValue: DefaultValue = async ({ row, ref, db, storage, auth, logging }) => {
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
}`;
};
export default defaultValue;
`;
}
return (

View File

@@ -11,6 +11,7 @@ import {
projectSettingsAtom,
rowyRunModalAtom,
} from "@src/atoms/projectScope";
import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope";
export interface IFieldsDropdownProps {
value: FieldType | "";
@@ -35,17 +36,22 @@ export default function FieldsDropdown({
}: IFieldsDropdownProps) {
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
const openRowyRunModal = useSetAtom(rowyRunModalAtom, projectScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const fieldTypesToDisplay = optionsProp
? FIELDS.filter((fieldConfig) => optionsProp.indexOf(fieldConfig.type) > -1)
: FIELDS;
const options = fieldTypesToDisplay.map((fieldConfig) => {
const requireCloudFunctionSetup =
fieldConfig.requireCloudFunction && !projectSettings.rowyRunUrl;
const requireCollectionTable =
tableSettings.isCollection === false &&
fieldConfig.requireCollectionTable === true;
return {
label: fieldConfig.name,
value: fieldConfig.type,
disabled: requireCloudFunctionSetup,
disabled: requireCloudFunctionSetup || requireCollectionTable,
requireCloudFunctionSetup,
requireCollectionTable,
};
});
@@ -82,7 +88,18 @@ export default function FieldsDropdown({
{getFieldProp("icon", option.value as FieldType)}
</ListItemIcon>
<Typography>{option.label}</Typography>
{option.requireCloudFunctionSetup && (
{option.requireCollectionTable ? (
<Typography
color="error"
variant="inherit"
component="span"
marginLeft={1}
className={"require-cloud-function"}
>
{" "}
Unavailable
</Typography>
) : option.requireCloudFunctionSetup ? (
<Typography
color="error"
variant="inherit"
@@ -107,7 +124,7 @@ export default function FieldsDropdown({
Cloud Function
</span>
</Typography>
)}
) : null}
</>
)}
label={label || "Field type"}

View File

@@ -35,6 +35,7 @@ export interface IFieldWrapperProps {
fieldName?: string;
label?: React.ReactNode;
debugText?: React.ReactNode;
debugValue?: React.ReactNode;
disabled?: boolean;
hidden?: boolean;
index?: number;
@@ -46,6 +47,7 @@ export default function FieldWrapper({
fieldName,
label,
debugText,
debugValue,
disabled,
hidden,
index,
@@ -100,7 +102,7 @@ export default function FieldWrapper({
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
<Suspense fallback={<FieldSkeleton />}>
{children ??
(!debugText && (
(!debugValue && (
<Typography
variant="body2"
color="text.secondary"
@@ -112,7 +114,7 @@ export default function FieldWrapper({
</Suspense>
</ErrorBoundary>
{debugText && (
{debugValue && (
<Stack direction="row" alignItems="center">
<Typography
variant="body2"
@@ -131,7 +133,7 @@ export default function FieldWrapper({
</Typography>
<IconButton
onClick={() => {
copyToClipboard(debugText as string);
copyToClipboard(debugValue as string);
enqueueSnackbar("Copied!");
}}
>
@@ -139,7 +141,7 @@ export default function FieldWrapper({
</IconButton>
<IconButton
href={`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${(
debugText as string
debugValue as string
).replace(/\//g, "~2F")}`}
target="_blank"
rel="noopener"

View File

@@ -6,6 +6,7 @@ import FieldWrapper from "./FieldWrapper";
import { IFieldConfig } from "@src/components/fields/types";
import { getFieldProp } from "@src/components/fields";
import { ColumnConfig, TableRowRef } from "@src/types/table";
import { TableRow } from "@src/types/table";
export interface IMemoizedFieldProps {
field: ColumnConfig;
@@ -16,6 +17,7 @@ export interface IMemoizedFieldProps {
isDirty: boolean;
onDirty: (fieldName: string) => void;
onSubmit: (fieldName: string, value: any) => void;
row: TableRow;
}
export const MemoizedField = memo(
@@ -28,6 +30,7 @@ export const MemoizedField = memo(
isDirty,
onDirty,
onSubmit,
row,
...props
}: IMemoizedFieldProps) {
const [localValue, setLocalValue, localValueRef] = useStateRef(value);
@@ -40,11 +43,7 @@ export const MemoizedField = memo(
onSubmit(field.fieldName, localValueRef.current);
}, [field.fieldName, localValueRef, onSubmit]);
// Derivative/aggregate field support
let type = field.type;
if (field.config && field.config.renderFieldType) {
type = field.config.renderFieldType;
}
const fieldComponent: IFieldConfig["SideDrawerField"] = getFieldProp(
"SideDrawerField",
@@ -78,6 +77,7 @@ export const MemoizedField = memo(
},
onSubmit: handleSubmit,
disabled,
row,
})}
</FieldWrapper>
);

View File

@@ -30,11 +30,21 @@ export default function SideDrawer() {
const [cell, setCell] = useAtom(selectedCellAtom, tableScope);
const [open, setOpen] = useAtom(sideDrawerOpenAtom, tableScope);
const selectedRow = find(tableRows, ["_rowy_ref.path", cell?.path]);
const selectedCellRowIndex = findIndex(tableRows, [
"_rowy_ref.path",
cell?.path,
]);
const selectedRow = find(
tableRows,
cell?.arrayIndex === undefined
? ["_rowy_ref.path", cell?.path]
: // if the table is an array table, we need to use the array index to find the row
["_rowy_ref.arrayTableData.index", cell?.arrayIndex]
);
const selectedCellRowIndex = findIndex(
tableRows,
cell?.arrayIndex === undefined
? ["_rowy_ref.path", cell?.path]
: // if the table is an array table, we need to use the array index to find the row
["_rowy_ref.arrayTableData.index", cell?.arrayIndex]
);
const handleNavigate = (direction: "up" | "down") => () => {
if (!tableRows || !cell) return;
@@ -45,8 +55,9 @@ export default function SideDrawer() {
setCell((cell) => ({
columnKey: cell!.columnKey,
path: newPath,
path: cell?.arrayIndex !== undefined ? cell.path : newPath,
focusInside: false,
arrayIndex: cell?.arrayIndex !== undefined ? rowIndex : undefined,
}));
};

View File

@@ -66,7 +66,16 @@ export default function SideDrawerFields({ row }: ISideDrawerFieldsProps) {
setSaveState("saving");
try {
await updateField({ path: selectedCell!.path, fieldName, value });
await updateField({
path: selectedCell!.path,
fieldName,
value,
deleteField: undefined,
arrayTableData: {
index: selectedCell.arrayIndex,
},
});
setSaveState("saved");
} catch (e) {
enqueueSnackbar((e as Error).message, { variant: "error" });
@@ -121,6 +130,7 @@ export default function SideDrawerFields({ row }: ISideDrawerFieldsProps) {
onDirty={onDirty}
onSubmit={onSubmit}
isDirty={dirtyField === field.key}
row={row}
/>
))}
@@ -128,7 +138,17 @@ export default function SideDrawerFields({ row }: ISideDrawerFieldsProps) {
type="debug"
fieldName="_rowy_ref.path"
label="Document path"
debugText={row._rowy_ref.path ?? row._rowy_ref.id ?? "No ref"}
debugText={
row._rowy_ref.arrayTableData
? row._rowy_ref.path +
" → " +
row._rowy_ref.arrayTableData.parentField +
"[" +
row._rowy_ref.arrayTableData.index +
"]"
: row._rowy_ref.path
}
debugValue={row._rowy_ref.path ?? row._rowy_ref.id ?? "No ref"}
/>
{userDocHiddenFields.length > 0 && (

View File

@@ -3,12 +3,19 @@ import { Copy as CopyCells } from "@src/assets/icons";
import Paste from "@mui/icons-material/ContentPaste";
import { IFieldConfig } from "@src/components/fields/types";
import { useMenuAction } from "@src/components/Table/useMenuAction";
import { useAtom } from "jotai";
import { tableSchemaAtom, tableScope } from "@src/atoms/tableScope";
import { SUPPORTED_TYPES_PASTE } from "@src/components/Table/useMenuAction";
// TODO: Remove this and add `handlePaste` function to column config
export const BasicContextMenuActions: IFieldConfig["contextMenuActions"] = (
selectedCell,
reset
) => {
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const selectedCol = tableSchema.columns?.[selectedCell.columnKey];
const handleClose = async () => await reset?.();
const { handleCopy, handlePaste, cellValue } = useMenuAction(
selectedCell,
@@ -24,9 +31,17 @@ export const BasicContextMenuActions: IFieldConfig["contextMenuActions"] = (
disabled:
cellValue === undefined || cellValue === null || cellValue === "",
},
{ label: "Paste", icon: <Paste />, onClick: handlePaste },
];
if (SUPPORTED_TYPES_PASTE.has(selectedCol?.type)) {
contextMenuActions.push({
label: "Paste",
icon: <Paste />,
onClick: handlePaste,
disabled: false,
});
}
return contextMenuActions;
};

View File

@@ -33,6 +33,7 @@ import {
deleteRowAtom,
updateFieldAtom,
tableFiltersPopoverAtom,
_updateRowDbAtom,
} from "@src/atoms/tableScope";
import { FieldType } from "@src/constants/fields";
@@ -52,6 +53,7 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
const addRow = useSetAtom(addRowAtom, tableScope);
const deleteRow = useSetAtom(deleteRowAtom, tableScope);
const updateField = useSetAtom(updateFieldAtom, tableScope);
const [updateRowDb] = useAtom(_updateRowDbAtom, tableScope);
const openTableFiltersPopover = useSetAtom(
tableFiltersPopoverAtom,
tableScope
@@ -62,19 +64,111 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
if (!tableSchema.columns || !selectedCell) return null;
const selectedColumn = tableSchema.columns[selectedCell.columnKey];
const row = find(tableRows, ["_rowy_ref.path", selectedCell.path]);
const row = find(
tableRows,
selectedCell?.arrayIndex === undefined
? ["_rowy_ref.path", selectedCell.path]
: // if the table is an array table, we need to use the array index to find the row
["_rowy_ref.arrayTableData.index", selectedCell.arrayIndex]
);
if (!row) return null;
const actionGroups: IContextMenuItem[][] = [];
const handleDuplicate = () => {
addRow({
row,
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
});
const _duplicate = () => {
if (row._rowy_ref.arrayTableData !== undefined) {
if (!updateRowDb) return;
return updateRowDb("", {}, undefined, {
index: row._rowy_ref.arrayTableData.index,
operation: {
addRow: "bottom",
base: row,
},
});
}
return addRow({
row: row,
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
});
};
if (altPress || row._rowy_ref.arrayTableData !== undefined) {
_duplicate();
} else {
confirm({
title: "Duplicate row?",
body: (
<>
Row path:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row._rowy_ref.path}
</code>
</>
),
confirm: "Duplicate",
handleConfirm: _duplicate,
});
}
};
const handleDelete = () => deleteRow(row._rowy_ref.path);
const handleDelete = () => {
const _delete = () =>
deleteRow({
path: row._rowy_ref.path,
options: row._rowy_ref.arrayTableData,
});
if (altPress || row._rowy_ref.arrayTableData !== undefined) {
_delete();
} else {
confirm({
title: "Delete row?",
body: (
<>
Row path:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row._rowy_ref.path}
</code>
</>
),
confirm: "Delete",
confirmColor: "error",
handleConfirm: _delete,
});
}
};
const handleClearValue = () => {
const clearValue = () => {
updateField({
path: selectedCell.path,
fieldName: selectedColumn.fieldName,
arrayTableData: {
index: selectedCell.arrayIndex,
},
value: null,
deleteField: true,
});
onClose();
};
if (altPress || row._rowy_ref.arrayTableData !== undefined) {
clearValue();
} else {
confirm({
title: "Clear cell value?",
body: "The cells value cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: clearValue,
});
}
};
const rowActions: IContextMenuItem[] = [
{
label: "Copy ID",
@@ -112,51 +206,14 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
disabled:
tableSettings.tableType === "collectionGroup" ||
(!userRoles.includes("ADMIN") && tableSettings.readOnly),
onClick: altPress
? handleDuplicate
: () => {
confirm({
title: "Duplicate row?",
body: (
<>
Row path:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row._rowy_ref.path}
</code>
</>
),
confirm: "Duplicate",
handleConfirm: handleDuplicate,
});
onClose();
},
onClick: handleDuplicate,
},
{
label: altPress ? "Delete" : "Delete…",
color: "error",
icon: <DeleteIcon />,
disabled: !userRoles.includes("ADMIN") && tableSettings.readOnly,
onClick: altPress
? handleDelete
: () => {
confirm({
title: "Delete row?",
body: (
<>
Row path:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row._rowy_ref.path}
</code>
</>
),
confirm: "Delete",
confirmColor: "error",
handleConfirm: handleDelete,
});
onClose();
},
onClick: handleDelete,
},
];
@@ -185,13 +242,7 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
// Cell actions
// TODO: Add copy and paste here
const cellValue = row?.[selectedCell.columnKey];
const handleClearValue = () =>
updateField({
path: selectedCell.path,
fieldName: selectedColumn.fieldName,
value: null,
deleteField: true,
});
const columnFilters = getFieldProp(
"filter",
selectedColumn?.type === FieldType.derivative
@@ -218,18 +269,7 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
!row ||
cellValue === undefined ||
getFieldProp("group", selectedColumn?.type) === "Auditing",
onClick: altPress
? handleClearValue
: () => {
confirm({
title: "Clear cell value?",
body: "The cells value cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: handleClearValue,
});
onClose();
},
onClick: handleClearValue,
},
{
label: "Filter value",

View File

@@ -34,7 +34,7 @@ export default function EmptyTable() {
: false;
let contents = <></>;
if (hasData) {
if (tableSettings.isCollection !== false && hasData) {
contents = (
<>
<div>
@@ -72,47 +72,56 @@ export default function EmptyTable() {
Get started
</Typography>
<Typography>
There is no data in the Firestore collection:
{tableSettings.isCollection === false
? "There is no data in this Array Sub Table:"
: "There is no data in the Firestore collection:"}
<br />
<code>{tableSettings.collection}</code>
<code>
{tableSettings.collection}
{tableSettings.subTableKey?.length &&
`.${tableSettings.subTableKey}`}
</code>
</Typography>
</div>
<Grid container spacing={1}>
<Grid item xs>
<Typography paragraph>
You can import data from an external source:
</Typography>
{tableSettings.isCollection !== false && (
<>
<Grid item xs>
<Typography paragraph>
You can import data from an external source:
</Typography>
<ImportData
render={(onClick) => (
<Button
variant="contained"
color="primary"
startIcon={<ImportIcon />}
onClick={onClick}
>
Import data
</Button>
)}
PopoverProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "center",
},
transformOrigin: {
vertical: "top",
horizontal: "center",
},
}}
/>
</Grid>
<ImportData
render={(onClick) => (
<Button
variant="contained"
color="primary"
startIcon={<ImportIcon />}
onClick={onClick}
>
Import data
</Button>
)}
PopoverProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "center",
},
transformOrigin: {
vertical: "top",
horizontal: "center",
},
}}
/>
</Grid>
<Grid item>
<Divider orientation="vertical">
<Typography variant="overline">or</Typography>
</Divider>
</Grid>
<Grid item>
<Divider orientation="vertical">
<Typography variant="overline">or</Typography>
</Divider>
</Grid>
</>
)}
<Grid item xs>
<Typography paragraph>

View File

@@ -19,16 +19,18 @@ import {
addRowAtom,
deleteRowAtom,
contextMenuTargetAtom,
_updateRowDbAtom,
tableSchemaAtom,
} from "@src/atoms/tableScope";
export const FinalColumn = memo(function FinalColumn({
row,
focusInsideCell,
}: IRenderedTableCellProps) {
const [userRoles] = useAtom(userRolesAtom, projectScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [updateRowDb] = useAtom(_updateRowDbAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const addRow = useSetAtom(addRowAtom, tableScope);
const deleteRow = useSetAtom(deleteRowAtom, tableScope);
const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope);
@@ -36,15 +38,71 @@ export const FinalColumn = memo(function FinalColumn({
const confirm = useSetAtom(confirmDialogAtom, projectScope);
const [altPress] = useAtom(altPressAtom, projectScope);
const handleDelete = () => {
const _delete = () =>
deleteRow({
path: row.original._rowy_ref.path,
options: row.original._rowy_ref.arrayTableData,
});
if (altPress || row.original._rowy_ref.arrayTableData !== undefined) {
_delete();
} else {
confirm({
title: "Delete row?",
body: (
<>
Row path:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row.original._rowy_ref.path}
</code>
</>
),
confirm: "Delete",
confirmColor: "error",
handleConfirm: _delete,
});
}
};
const addRowIdType = tableSchema.idType || "decrement";
const handleDelete = () => deleteRow(row.original._rowy_ref.path);
const handleDuplicate = () => {
addRow({
row: row.original,
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
});
const _duplicate = () => {
if (row.original._rowy_ref.arrayTableData !== undefined) {
if (!updateRowDb) return;
return updateRowDb("", {}, undefined, {
index: row.original._rowy_ref.arrayTableData.index,
operation: {
addRow: "bottom",
base: row.original,
},
});
}
return addRow({
row: row.original,
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
});
};
if (altPress || row.original._rowy_ref.arrayTableData !== undefined) {
_duplicate();
} else {
confirm({
title: "Duplicate row?",
body: (
<>
Row path:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row.original._rowy_ref.path}
</code>
</>
),
confirm: "Duplicate",
handleConfirm: _duplicate,
});
}
};
if (!userRoles.includes("ADMIN") && tableSettings.readOnly === true)
@@ -76,28 +134,7 @@ export const FinalColumn = memo(function FinalColumn({
size="small"
color="inherit"
disabled={tableSettings.tableType === "collectionGroup"}
onClick={
altPress
? handleDuplicate
: () => {
confirm({
title: "Duplicate row?",
body: (
<>
Row path:
<br />
<code
style={{ userSelect: "all", wordBreak: "break-all" }}
>
{row.original._rowy_ref.path}
</code>
</>
),
confirm: "Duplicate",
handleConfirm: handleDuplicate,
});
}
}
onClick={handleDuplicate}
className="row-hover-iconButton"
tabIndex={focusInsideCell ? 0 : -1}
>
@@ -109,29 +146,7 @@ export const FinalColumn = memo(function FinalColumn({
<IconButton
size="small"
color="inherit"
onClick={
altPress
? handleDelete
: () => {
confirm({
title: "Delete row?",
body: (
<>
Row path:
<br />
<code
style={{ userSelect: "all", wordBreak: "break-all" }}
>
{row.original._rowy_ref.path}
</code>
</>
),
confirm: "Delete",
confirmColor: "error",
handleConfirm: handleDelete,
});
}
}
onClick={handleDelete}
className="row-hover-iconButton"
tabIndex={focusInsideCell ? 0 : -1}
sx={{

View File

@@ -211,8 +211,6 @@ export default function Table({
if (result.destination?.index === undefined || !result.draggableId)
return;
console.log(result.draggableId, result.destination.index);
updateColumn({
key: result.draggableId,
index: result.destination.index,

View File

@@ -83,7 +83,7 @@ export const TableBody = memo(function TableBody({
return (
<StyledRow
key={row.id}
key={row.id + row.original._rowy_ref.arrayTableData?.index}
role="row"
aria-rowindex={row.index + 2}
style={{
@@ -102,7 +102,10 @@ export const TableBody = memo(function TableBody({
const isSelectedCell =
selectedCell?.path === row.original._rowy_ref.path &&
selectedCell?.columnKey === cell.column.id;
selectedCell?.columnKey === cell.column.id &&
// if the table is an array sub table, we need to check the array index as well
selectedCell?.arrayIndex ===
row.original._rowy_ref.arrayTableData?.index;
const fieldTypeGroup = getFieldProp(
"group",

View File

@@ -66,6 +66,7 @@ export default function EditorCellController({
fieldName: props.column.fieldName,
value: localValueRef.current,
deleteField: localValueRef.current === undefined,
arrayTableData: props.row?._rowy_ref.arrayTableData,
});
} catch (e) {
enqueueSnackbar((e as Error).message, { variant: "error" });

View File

@@ -123,6 +123,7 @@ export const TableCell = memo(function TableCell({
focusInsideCell,
setFocusInsideCell: (focusInside: boolean) =>
setSelectedCell({
arrayIndex: row.original._rowy_ref.arrayTableData?.index,
path: row.original._rowy_ref.path,
columnKey: cell.column.id,
focusInside,
@@ -166,6 +167,7 @@ export const TableCell = memo(function TableCell({
}}
onClick={(e) => {
setSelectedCell({
arrayIndex: row.original._rowy_ref.arrayTableData?.index,
path: row.original._rowy_ref.path,
columnKey: cell.column.id,
focusInside: false,
@@ -174,6 +176,7 @@ export const TableCell = memo(function TableCell({
}}
onDoubleClick={(e) => {
setSelectedCell({
arrayIndex: row.original._rowy_ref.arrayTableData?.index,
path: row.original._rowy_ref.path,
columnKey: cell.column.id,
focusInside: true,
@@ -182,21 +185,15 @@ export const TableCell = memo(function TableCell({
}}
onContextMenu={(e) => {
e.preventDefault();
let isEditorCell = false;
setSelectedCell((prev) => {
isEditorCell = prev?.focusInside === true;
return {
arrayIndex: row.original._rowy_ref.arrayTableData?.index,
path: row.original._rowy_ref.path,
columnKey: cell.column.id,
focusInside: false,
// focusInside: !!!prev
// ? false
// : prev?.columnKey === cell.column.id &&
// prev.path === row.original._rowy_ref.path
// ? prev?.focusInside
// : false,
};
});
(e.target as HTMLDivElement).focus();

View File

@@ -46,8 +46,12 @@ export const TableHeader = memo(function TableHeader({
return (
<DragDropContext onDragEnd={handleDropColumn}>
{headerGroups.map((headerGroup) => (
<Droppable droppableId="droppable-column" direction="horizontal">
{headerGroups.map((headerGroup, _i) => (
<Droppable
key={_i}
droppableId="droppable-column"
direction="horizontal"
>
{(provided) => (
<StyledRow
key={headerGroup.id}

View File

@@ -128,6 +128,11 @@ export function useKeyboardNavigation({
? tableRows[newRowIndex]._rowy_ref.path
: "_rowy_header",
columnKey: leafColumns[newColIndex].id! || leafColumns[0].id!,
arrayIndex:
newRowIndex > -1
? tableRows[newRowIndex]._rowy_ref.arrayTableData?.index
: undefined,
// When selected cell changes, exit current cell
focusInside: false,
};

View File

@@ -15,16 +15,66 @@ import { ColumnConfig } from "@src/types/table";
import { FieldType } from "@src/constants/fields";
const SUPPORTED_TYPES = new Set([
import { format } from "date-fns";
import { DATE_FORMAT, DATE_TIME_FORMAT } from "@src/constants/dates";
import { isDate, isFunction } from "lodash-es";
import { getDurationString } from "@src/components/fields/Duration/utils";
export const SUPPORTED_TYPES_COPY = new Set([
// TEXT
FieldType.shortText,
FieldType.longText,
FieldType.number,
FieldType.email,
FieldType.percentage,
FieldType.phone,
FieldType.richText,
FieldType.email,
FieldType.phone,
FieldType.url,
// SELECT
FieldType.singleSelect,
FieldType.multiSelect,
// NUMERIC
FieldType.checkbox,
FieldType.number,
FieldType.percentage,
FieldType.rating,
FieldType.slider,
FieldType.color,
FieldType.geoPoint,
// DATE & TIME
FieldType.date,
FieldType.dateTime,
FieldType.duration,
// FILE
FieldType.image,
FieldType.file,
// CODE
FieldType.json,
FieldType.code,
FieldType.markdown,
FieldType.array,
// AUDIT
FieldType.createdBy,
FieldType.updatedBy,
FieldType.createdAt,
FieldType.updatedAt,
]);
export const SUPPORTED_TYPES_PASTE = new Set([
// TEXT
FieldType.shortText,
FieldType.longText,
FieldType.richText,
FieldType.email,
FieldType.phone,
FieldType.url,
// NUMERIC
FieldType.number,
FieldType.percentage,
FieldType.rating,
FieldType.slider,
// CODE
FieldType.json,
FieldType.code,
FieldType.markdown,
]);
export function useMenuAction(
@@ -35,15 +85,14 @@ export function useMenuAction(
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableRows] = useAtom(tableRowsAtom, tableScope);
const updateField = useSetAtom(updateFieldAtom, tableScope);
const [cellValue, setCellValue] = useState<string | undefined>();
const [cellValue, setCellValue] = useState<any>();
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
);
const value = getValue(cellValue);
await navigator.clipboard.writeText(value);
enqueueSnackbar("Copied");
} else {
await navigator.clipboard.writeText("");
@@ -56,21 +105,30 @@ export function useMenuAction(
const handleCut = useCallback(async () => {
try {
if (!selectedCell || !selectedCol || !cellValue) return;
if (!selectedCell || !selectedCol) return;
if (cellValue !== undefined && cellValue !== null && cellValue !== "") {
await navigator.clipboard.writeText(
typeof cellValue === "object" ? JSON.stringify(cellValue) : cellValue
);
const value = getValue(cellValue);
await navigator.clipboard.writeText(value);
enqueueSnackbar("Copied");
} else {
await navigator.clipboard.writeText("");
}
if (cellValue !== undefined)
if (
cellValue !== undefined &&
selectedCol.type !== FieldType.createdAt &&
selectedCol.type !== FieldType.updatedAt &&
selectedCol.type !== FieldType.createdBy &&
selectedCol.type !== FieldType.updatedBy &&
selectedCol.type !== FieldType.checkbox
)
updateField({
path: selectedCell.path,
fieldName: selectedCol.fieldName,
value: undefined,
deleteField: true,
arrayTableData: {
index: selectedCell.arrayIndex,
},
});
} catch (error) {
enqueueSnackbar(`Failed to cut: ${error}`, { variant: "error" });
@@ -92,7 +150,7 @@ export function useMenuAction(
try {
text = await navigator.clipboard.readText();
} catch (e) {
enqueueSnackbar(`Read clilboard permission denied.`, {
enqueueSnackbar(`Read clipboard permission denied.`, {
variant: "error",
});
return;
@@ -111,10 +169,29 @@ export function useMenuAction(
parsed = JSON.parse(text);
break;
}
if (selectedCol.type === FieldType.slider) {
if (parsed < selectedCol.config?.min) parsed = selectedCol.config?.min;
else if (parsed > selectedCol.config?.max)
parsed = selectedCol.config?.max;
}
if (selectedCol.type === FieldType.rating) {
if (parsed < 0) parsed = 0;
if (parsed > (selectedCol.config?.max || 5))
parsed = selectedCol.config?.max || 5;
}
if (selectedCol.type === FieldType.percentage) {
parsed = parsed / 100;
}
updateField({
path: selectedCell.path,
fieldName: selectedCol.fieldName,
value: parsed,
arrayTableData: {
index: selectedCell.arrayIndex,
},
});
} catch (error) {
enqueueSnackbar(
@@ -130,32 +207,129 @@ export function useMenuAction(
const selectedCol = tableSchema.columns?.[selectedCell.columnKey];
if (!selectedCol) return setCellValue("");
setSelectedCol(selectedCol);
const selectedRow = find(tableRows, ["_rowy_ref.path", selectedCell.path]);
const selectedRow = find(
tableRows,
selectedCell.arrayIndex === undefined
? ["_rowy_ref.path", selectedCell.path]
: // if the table is an array table, we need to use the array index to find the row
["_rowy_ref.arrayTableData.index", selectedCell.arrayIndex]
);
setCellValue(get(selectedRow, selectedCol.fieldName));
}, [selectedCell, tableSchema, tableRows]);
const checkEnabled = useCallback(
const checkEnabledCopy = useCallback(
(func: Function) => {
if (!selectedCol) {
return function () {
enqueueSnackbar(`No selected cell`, {
variant: "error",
});
};
}
const fieldType = getFieldType(selectedCol);
return function () {
if (SUPPORTED_TYPES.has(selectedCol?.type)) {
if (SUPPORTED_TYPES_COPY.has(fieldType)) {
return func();
} else {
enqueueSnackbar(`${fieldType} field cannot be copied`, {
variant: "error",
});
}
};
},
[enqueueSnackbar, selectedCol?.type]
);
const checkEnabledPaste = useCallback(
(func: Function) => {
if (!selectedCol) {
return function () {
enqueueSnackbar(`No selected cell`, {
variant: "error",
});
};
}
const fieldType = getFieldType(selectedCol);
return function () {
if (SUPPORTED_TYPES_PASTE.has(fieldType)) {
return func();
} else {
enqueueSnackbar(
`${selectedCol?.type} field cannot be copied using keyboard shortcut`,
`${fieldType} field does not support paste functionality`,
{
variant: "info",
variant: "error",
}
);
}
};
},
[selectedCol]
[enqueueSnackbar, selectedCol?.type]
);
const getValue = useCallback(
(cellValue: any) => {
switch (selectedCol?.type) {
case FieldType.percentage:
return cellValue * 100;
case FieldType.json:
case FieldType.color:
case FieldType.geoPoint:
return JSON.stringify(cellValue);
case FieldType.date:
if (
(!!cellValue && isFunction(cellValue.toDate)) ||
isDate(cellValue)
) {
try {
return format(
isDate(cellValue) ? cellValue : cellValue.toDate(),
selectedCol.config?.format || DATE_FORMAT
);
} catch (e) {
return;
}
}
return;
case FieldType.dateTime:
case FieldType.createdAt:
case FieldType.updatedAt:
if (
(!!cellValue && isFunction(cellValue.toDate)) ||
isDate(cellValue)
) {
try {
return format(
isDate(cellValue) ? cellValue : cellValue.toDate(),
selectedCol.config?.format || DATE_TIME_FORMAT
);
} catch (e) {
return;
}
}
return;
case FieldType.duration:
return getDurationString(
cellValue.start.toDate(),
cellValue.end.toDate()
);
case FieldType.image:
case FieldType.file:
return cellValue[0].downloadURL;
case FieldType.createdBy:
case FieldType.updatedBy:
return cellValue.displayName;
default:
return cellValue;
}
},
[cellValue, selectedCol]
);
return {
handleCopy: checkEnabled(handleCopy),
handleCut: checkEnabled(handleCut),
handlePaste: handlePaste,
handleCopy: checkEnabledCopy(handleCopy),
handleCut: checkEnabledCopy(handleCut),
handlePaste: checkEnabledPaste(handlePaste),
cellValue,
};
}

View File

@@ -62,7 +62,7 @@ export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"];
const extensionBodyTemplate = {
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")
// Import any NPM package needed
@@ -90,10 +90,10 @@ const extensionBodyTemplate = {
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, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
return ({
@@ -101,20 +101,20 @@ const extensionBodyTemplate = {
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, 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, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
return ({
@@ -123,10 +123,10 @@ 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, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
return({
@@ -135,10 +135,10 @@ const extensionBodyTemplate = {
index: "", // meili search index to sync to
objectID: ref.id, // meili search 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, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("extensionBody started")
return ({
@@ -147,10 +147,10 @@ const extensionBodyTemplate = {
index: "", // bigquery dataset to sync to
objectID: ref.id, // bigquery 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, 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
@@ -162,10 +162,10 @@ 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, 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
@@ -187,10 +187,10 @@ 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, 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
@@ -202,10 +202,10 @@ 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, 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
@@ -218,10 +218,10 @@ const extensionBodyTemplate = {
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, 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
@@ -259,7 +259,7 @@ const extensionBodyTemplate = {
// 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
}`,
};
@@ -276,11 +276,11 @@ export function emptyExtensionObject(
requiredFields: [],
trackedFields: [],
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,
};

View File

@@ -67,7 +67,7 @@ export const fieldParser = (fieldType: FieldType) => {
case FieldType.dateTime:
return (v: string) => {
const date = parseISO(v);
return isValidDate(date) ? date.getTime() : null;
return isValidDate(date) ? new Date(date) : null;
};
default:
return (v: any) => v;

View File

@@ -8,7 +8,7 @@ import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
import { TableRowRef } from "@src/types/table";
import SnackbarProgress from "@src/components/SnackbarProgress";
const MAX_CONCURRENT_TASKS = 10;
const MAX_CONCURRENT_TASKS = 1000;
type UploadParamTypes = {
docRef: TableRowRef;

View File

@@ -1,10 +1,7 @@
import { Typography } from "@mui/material";
import WarningIcon from "@mui/icons-material/WarningAmber";
import { TableSettings } from "@src/types/table";
import {
IWebhook,
ISecret,
} from "@src/components/TableModals/WebhooksModal/utils";
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
const requestType = [
"declare type WebHookRequest {",
@@ -101,11 +98,7 @@ export const webhookBasic = {
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<Typography color="text.disabled">
<WarningIcon aria-label="Warning" style={{ verticalAlign: "bottom" }} />

View File

@@ -1,10 +1,7 @@
import { Typography, Link, TextField } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { TableSettings } from "@src/types/table";
import {
IWebhook,
ISecret,
} from "@src/components/TableModals/WebhooksModal/utils";
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
export const webhookFirebaseAuth = {
name: "firebaseAuth",
@@ -41,11 +38,7 @@ export const webhookFirebaseAuth = {
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<>
<Typography variant="inherit" paragraph>

View File

@@ -1,10 +1,7 @@
import { Typography, Link, TextField } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { TableSettings } from "@src/types/table";
import {
IWebhook,
ISecret,
} from "@src/components/TableModals/WebhooksModal/utils";
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
export const webhookSendgrid = {
name: "SendGrid",
@@ -51,11 +48,7 @@ export const webhookSendgrid = {
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<>
<Typography gutterBottom>

View File

@@ -1,14 +1,18 @@
import { Typography, Link, TextField, Alert } from "@mui/material";
import { useAtom } from "jotai";
import { Typography, Link, TextField, Alert, Box } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { TableSettings } from "@src/types/table";
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
import {
IWebhook,
ISecret,
} from "@src/components/TableModals/WebhooksModal/utils";
projectScope,
secretNamesAtom,
updateSecretNamesAtom,
} from "@src/atoms/projectScope";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select";
import LoadingButton from "@mui/lab/LoadingButton";
export const webhookStripe = {
name: "Stripe",
@@ -49,11 +53,10 @@ export const webhookStripe = {
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
const [secretNames] = useAtom(secretNamesAtom, projectScope);
const [updateSecretNames] = useAtom(updateSecretNamesAtom, projectScope);
return (
<>
<Typography gutterBottom>
@@ -77,8 +80,9 @@ export const webhookStripe = {
</Typography>
{webhookObject.auth.secretKey &&
!secrets.loading &&
!secrets.keys.includes(webhookObject.auth.secretKey) && (
!secretNames.loading &&
secretNames.secretNames &&
!secretNames.secretNames.includes(webhookObject.auth.secretKey) && (
<Alert severity="error" sx={{ height: "auto!important" }}>
Your previously selected key{" "}
<code>{webhookObject.auth.secretKey}</code> does not exist in
@@ -86,34 +90,55 @@ export const webhookStripe = {
</Alert>
)}
<FormControl fullWidth margin={"normal"}>
<InputLabel id="stripe-secret-key">Secret key</InputLabel>
<Select
labelId="stripe-secret-key"
id="stripe-secret-key"
label="Secret key"
variant="filled"
value={webhookObject.auth.secretKey}
onChange={(e) => {
setWebhookObject({
...webhookObject,
auth: { ...webhookObject.auth, secretKey: e.target.value },
});
}}
>
{secrets.keys.map((secret) => {
return <MenuItem value={secret}>{secret}</MenuItem>;
})}
<MenuItem
onClick={() => {
const secretManagerLink = `https://console.cloud.google.com/security/secret-manager/create?project=${secrets.projectId}`;
window?.open?.(secretManagerLink, "_blank")?.focus();
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginY: 1,
}}
>
<FormControl fullWidth>
<InputLabel id="stripe-secret-key">Secret key</InputLabel>
<Select
labelId="stripe-secret-key"
id="stripe-secret-key"
label="Secret key"
variant="filled"
value={webhookObject.auth.secretKey}
onChange={(e) => {
setWebhookObject({
...webhookObject,
auth: { ...webhookObject.auth, secretKey: e.target.value },
});
}}
>
Add a key in Secret Manager
</MenuItem>
</Select>
</FormControl>
{secretNames.secretNames?.map((secret) => {
return <MenuItem value={secret}>{secret}</MenuItem>;
})}
<MenuItem
onClick={() => {
const secretManagerLink = `https://console.cloud.google.com/security/secret-manager/create`;
window?.open?.(secretManagerLink, "_blank")?.focus();
}}
>
Add a key in Secret Manager
</MenuItem>
</Select>
</FormControl>
<LoadingButton
sx={{
height: "100%",
marginLeft: 1,
}}
loading={secretNames.loading}
onClick={() => {
updateSecretNames?.();
}}
>
Refresh
</LoadingButton>
</Box>
<TextField
id="stripe-signing-secret"
label="Signing key"

View File

@@ -1,10 +1,7 @@
import { Typography, Link, TextField } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { TableSettings } from "@src/types/table";
import {
IWebhook,
ISecret,
} from "@src/components/TableModals/WebhooksModal/utils";
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
export const webhookTypeform = {
name: "Typeform",
@@ -83,11 +80,7 @@ export const webhookTypeform = {
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<>
<Typography gutterBottom>

View File

@@ -1,10 +1,7 @@
import { Typography, Link, TextField } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { TableSettings } from "@src/types/table";
import {
IWebhook,
ISecret,
} from "@src/components/TableModals/WebhooksModal/utils";
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
export const webhook = {
name: "Web Form",
@@ -51,11 +48,7 @@ export const webhook = {
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<>
<Typography gutterBottom>

View File

@@ -1,41 +1,13 @@
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { IWebhookModalStepProps } from "./WebhookModal";
import { FormControlLabel, Checkbox, Typography } from "@mui/material";
import {
projectIdAtom,
projectScope,
rowyRunAtom,
} from "@src/atoms/projectScope";
import { runRoutes } from "@src/constants/runRoutes";
import { webhookSchemas, ISecret } from "./utils";
import { webhookSchemas } from "./utils";
export default function Step1Endpoint({
webhookObject,
setWebhookObject,
}: IWebhookModalStepProps) {
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
const [projectId] = useAtom(projectIdAtom, projectScope);
const [secrets, setSecrets] = useState<ISecret>({
loading: true,
keys: [],
projectId,
});
useEffect(() => {
rowyRun({
route: runRoutes.listSecrets,
}).then((secrets) => {
setSecrets({
loading: false,
keys: secrets as string[],
projectId,
});
});
}, []);
return (
<>
<Typography variant="inherit" paragraph>
@@ -63,10 +35,9 @@ export default function Step1Endpoint({
/>
{webhookObject.auth?.enabled &&
webhookSchemas[webhookObject.type].auth(
webhookSchemas[webhookObject.type].Auth(
webhookObject,
setWebhookObject,
secrets
setWebhookObject
)}
{}
</>

View File

@@ -119,12 +119,6 @@ export interface IWebhook {
auth?: any;
}
export interface ISecret {
loading: boolean;
keys: string[];
projectId: string;
}
export const webhookSchemas = {
basic,
typeform,

View File

@@ -23,6 +23,8 @@ import {
tableFiltersAtom,
tableSortsAtom,
addRowAtom,
_updateRowDbAtom,
tableColumnsOrderedAtom,
tableSchemaAtom,
updateTableSchemaAtom,
} from "@src/atoms/tableScope";
@@ -214,3 +216,100 @@ export default function AddRow() {
</>
);
}
export function AddRowArraySubTable() {
const [updateRowDb] = useAtom(_updateRowDbAtom, tableScope);
const [open, setOpen] = useState(false);
const anchorEl = useRef<HTMLDivElement>(null);
const [addRowAt, setAddNewRowAt] = useState<"top" | "bottom">("bottom");
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
if (!updateRowDb) return null;
const handleClick = () => {
const initialValues: Record<string, any> = {};
// Set initial values based on default values
for (const column of tableColumnsOrdered) {
if (column.config?.defaultValue?.type === "static")
initialValues[column.key] = column.config.defaultValue.value!;
else if (column.config?.defaultValue?.type === "null")
initialValues[column.key] = null;
}
updateRowDb("", initialValues, undefined, {
index: 0,
operation: {
addRow: addRowAt,
},
});
};
return (
<>
<ButtonGroup
variant="contained"
color="primary"
aria-label="Split button"
ref={anchorEl}
>
<Button
variant="contained"
color="primary"
onClick={handleClick}
startIcon={addRowAt === "top" ? <AddRowTopIcon /> : <AddRowIcon />}
>
Add row to {addRowAt}
</Button>
<Button
variant="contained"
color="primary"
aria-label="Select row add position"
aria-haspopup="menu"
style={{ padding: 0 }}
onClick={() => setOpen(true)}
id="add-row-menu-button"
aria-controls={open ? "add-row-menu" : undefined}
aria-expanded={open ? "true" : "false"}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Select
id="add-row-menu"
open={open}
onClose={() => setOpen(false)}
label="Row add position"
style={{ display: "none" }}
value={addRowAt}
onChange={(e) => setAddNewRowAt(e.target.value as typeof addRowAt)}
MenuProps={{
anchorEl: anchorEl.current,
MenuListProps: { "aria-labelledby": "add-row-menu-button" },
anchorOrigin: { horizontal: "left", vertical: "bottom" },
transformOrigin: { horizontal: "left", vertical: "top" },
}}
>
<MenuItem value="top">
<ListItemText
primary="To top"
secondary="Adds a new row to the top of this table"
secondaryTypographyProps={{ variant: "caption" }}
/>
</MenuItem>
<MenuItem value="bottom">
<ListItemText
primary="To bottom"
secondary={"Adds a new row to the bottom of this table"}
secondaryTypographyProps={{
variant: "caption",
whiteSpace: "pre-line",
}}
/>
</MenuItem>
</Select>
</>
);
}

View File

@@ -1,17 +1,19 @@
import { lazy, Suspense } from "react";
import { useAtom, useSetAtom } from "jotai";
import { Stack } from "@mui/material";
import { Button, Stack } from "@mui/material";
import WebhookIcon from "@mui/icons-material/Webhook";
import {
Export as ExportIcon,
Extension as ExtensionIcon,
CloudLogs as CloudLogsIcon,
Import as ImportIcon,
} from "@src/assets/icons";
import TableToolbarButton from "./TableToolbarButton";
import { ButtonSkeleton } from "./TableToolbarSkeleton";
import AddRow from "./AddRow";
import AddRow, { AddRowArraySubTable } from "./AddRow";
import LoadedRowsStatus from "./LoadedRowsStatus";
import TableSettings from "./TableSettings";
import HiddenFields from "./HiddenFields";
@@ -32,6 +34,8 @@ import {
tableModalAtom,
} from "@src/atoms/tableScope";
import { FieldType } from "@src/constants/fields";
import { TableToolsType } from "@src/types/table";
import FilterIcon from "@mui/icons-material/FilterList";
// prettier-ignore
const Filters = lazy(() => import("./Filters" /* webpackChunkName: "Filters" */));
@@ -43,7 +47,11 @@ const ReExecute = lazy(() => import("./ReExecute" /* webpackChunkName: "ReExecut
export const TABLE_TOOLBAR_HEIGHT = 44;
export default function TableToolbar() {
export default function TableToolbar({
disabledTools,
}: {
disabledTools?: TableToolsType[];
}) {
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
const [userRoles] = useAtom(userRolesAtom, projectScope);
const [compatibleRowyRunVersion] = useAtom(
@@ -54,7 +62,6 @@ export default function TableToolbar() {
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const openTableModal = useSetAtom(tableModalAtom, tableScope);
const hasDerivatives =
Object.values(tableSchema.columns ?? {}).filter(
(column) => column.type === FieldType.derivative
@@ -64,6 +71,7 @@ export default function TableToolbar() {
tableSchema.compiledExtension &&
tableSchema.compiledExtension.replace(/\W/g, "")?.length > 0;
disabledTools = disabledTools ?? [];
return (
<Stack
direction="row"
@@ -87,35 +95,61 @@ export default function TableToolbar() {
},
}}
>
<AddRow />
{tableSettings.isCollection === false ? (
<AddRowArraySubTable />
) : (
<AddRow />
)}
<div /> {/* Spacer */}
<HiddenFields />
<Suspense fallback={<ButtonSkeleton />}>
<Filters />
</Suspense>
{tableSettings.isCollection === false ? (
<Button
variant="outlined"
color="primary"
startIcon={<FilterIcon />}
disabled={true}
>
Filter
</Button>
) : (
<Suspense fallback={<ButtonSkeleton />}>
<Filters />
</Suspense>
)}
<div /> {/* Spacer */}
<LoadedRowsStatus />
<div style={{ flexGrow: 1, minWidth: 64 }} />
<RowHeight />
<div /> {/* Spacer */}
{tableSettings.tableType !== "collectionGroup" && (
<Suspense fallback={<ButtonSkeleton />}>
<ImportData />
</Suspense>
{disabledTools.includes("import") ? (
<TableToolbarButton
title="Import data"
icon={<ImportIcon />}
disabled={true}
/>
) : (
tableSettings.tableType !== "collectionGroup" && (
<Suspense fallback={<ButtonSkeleton />}>
<ImportData />
</Suspense>
)
)}
{(!projectSettings.exporterRoles ||
projectSettings.exporterRoles.length === 0 ||
userRoles.some((role) =>
projectSettings.exporterRoles?.includes(role)
)) && (
<Suspense fallback={<ButtonSkeleton />}>
<TableToolbarButton
title="Export/Download"
onClick={() => openTableModal("export")}
icon={<ExportIcon />}
/>
<Suspense fallback={<ButtonSkeleton />}>
<TableToolbarButton
title="Export/Download"
onClick={() => openTableModal("export")}
icon={<ExportIcon />}
disabled={disabledTools.includes("export")}
/>
</Suspense>
)}
{userRoles.includes("ADMIN") && (
<>
<div /> {/* Spacer */}
@@ -129,6 +163,7 @@ export default function TableToolbar() {
}
}}
icon={<WebhookIcon />}
disabled={disabledTools.includes("webhooks")}
/>
<TableToolbarButton
title="Extensions"
@@ -137,6 +172,7 @@ export default function TableToolbar() {
else openRowyRunModal({ feature: "Extensions" });
}}
icon={<ExtensionIcon />}
disabled={disabledTools.includes("extensions")}
/>
<TableToolbarButton
title="Cloud logs"
@@ -145,6 +181,7 @@ export default function TableToolbar() {
if (projectSettings.rowyRunUrl) openTableModal("cloudLogs");
else openRowyRunModal({ feature: "Cloud logs" });
}}
disabled={disabledTools.includes("cloud_logs")}
/>
{(hasDerivatives || hasExtensions) && (
<Suspense fallback={<ButtonSkeleton />}>

View File

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

View File

@@ -1,67 +1,69 @@
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 RUN_ACTION_TEMPLATE = `// Import any NPM package needed
// import _ from "lodash";
const action: Action = async ({ row, ref, db, storage, auth, actionParams, user, logging }) => {
logging.log("action started");
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")
// Example:
const authToken = await rowy.secrets.get("service");
try {
const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{
method: 'DELETE',
const resp = await fetch("https://example.com/api/v1/users/" + ref.id, {
method: "PUT",
headers: {
'Content-Type': 'application/json',
'Authorization': authToken
"Content-Type": "application/json",
Authorization: authToken,
},
body: JSON.stringify(row)
})
body: JSON.stringify(row),
});
return {
success: true,
message: 'User deleted successfully on example service',
status: null
}
message: "User updated successfully on example service",
status: "upto date",
};
} catch (error) {
return {
success: false,
message: 'User delete failed on example service',
}
message: "User update failed on example service",
};
}
*/
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`;
};
export default action;
`;
export const UNDO_ACTION_TEMPLATE = `// Import any NPM package needed
// import _ from "lodash";
const action: Action = async ({ row, ref, db, storage, auth, actionParams, user, logging }) => {
logging.log("action started");
/*
// 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 default action;
`;

View File

@@ -11,7 +11,7 @@ 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 { TableRow, TableRowRef } from "@src/types/table";
import AddButton from "./AddButton";
import { getPseudoColumn } from "./utils";
@@ -29,6 +29,7 @@ function ArrayFieldInput({
onRemove,
onSubmit,
id,
row,
}: {
index: number;
onRemove: (index: number) => void;
@@ -37,6 +38,7 @@ function ArrayFieldInput({
onSubmit: () => void;
_rowy_ref: TableRowRef;
id: string;
row: TableRow;
}) {
const typeDetected = detectType(value);
@@ -80,6 +82,7 @@ function ArrayFieldInput({
column={getPseudoColumn(typeDetected, index, value)}
value={value}
_rowy_ref={_rowy_ref}
row={row}
/>
</Stack>
<Box
@@ -174,6 +177,7 @@ export default function ArraySideDrawerField({
onRemove={handleRemove}
index={index}
onSubmit={onSubmit}
row={props.row}
/>
))}
{provided.placeholder}

View File

@@ -6,6 +6,8 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import DisplayCell from "./DisplayCell";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Array" */)
@@ -26,5 +28,6 @@ export const config: IFieldConfig = {
}),
SideDrawerField,
requireConfiguration: false,
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -0,0 +1,46 @@
import { IDisplayCellProps } from "@src/components/fields/types";
import { Link } from "react-router-dom";
import { Stack, IconButton } from "@mui/material";
import OpenIcon from "@mui/icons-material/OpenInBrowser";
import { useSubTableData } from "./utils";
export default function ArraySubTable({
column,
row,
_rowy_ref,
tabIndex,
}: IDisplayCellProps) {
const { documentCount, label, subTablePath } = useSubTableData(
column as any,
row,
_rowy_ref
);
if (!_rowy_ref) return null;
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
style={{ paddingLeft: "var(--cell-padding)", width: "100%" }}
>
<div style={{ flexGrow: 1, overflow: "hidden" }}>
{documentCount} {column.name as string}: {label}
</div>
<IconButton
component={Link}
to={subTablePath}
className="row-hover-iconButton end"
size="small"
disabled={!subTablePath}
tabIndex={tabIndex}
>
<OpenIcon />
</IconButton>
</Stack>
);
}

View File

@@ -0,0 +1,32 @@
import { useAtom } from "jotai";
import { ISettingsProps } from "@src/components/fields/types";
import MultiSelect from "@rowy/multiselect";
import { FieldType } from "@src/constants/fields";
import { tableScope, tableColumnsOrderedAtom } from "@src/atoms/tableScope";
const Settings = ({ config, onChange }: ISettingsProps) => {
const [tableOrderedColumns] = useAtom(tableColumnsOrderedAtom, tableScope);
const columnOptions = tableOrderedColumns
.filter((column) =>
[
FieldType.shortText,
FieldType.singleSelect,
FieldType.email,
FieldType.phone,
].includes(column.type)
)
.map((c) => ({ label: c.name, value: c.key }));
return (
<MultiSelect
label="Parent label"
options={columnOptions}
value={config.parentLabel ?? []}
onChange={onChange("parentLabel")}
/>
);
};
export default Settings;

View File

@@ -0,0 +1,56 @@
import { useMemo } from "react";
import { useAtom } from "jotai";
import { selectAtom } from "jotai/utils";
import { find, isEqual } from "lodash-es";
import { ISideDrawerFieldProps } from "@src/components/fields/types";
import { Link } from "react-router-dom";
import { Box, Stack, IconButton } from "@mui/material";
import OpenIcon from "@mui/icons-material/OpenInBrowser";
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
import { useSubTableData } from "./utils";
export default function ArraySubTable({
column,
_rowy_ref,
}: ISideDrawerFieldProps) {
const [row] = useAtom(
useMemo(
() =>
selectAtom(
tableRowsAtom,
(tableRows) => find(tableRows, ["_rowy_ref.path", _rowy_ref.path]),
isEqual
),
[_rowy_ref.path]
),
tableScope
);
const { documentCount, label, subTablePath } = useSubTableData(
column as any,
row as any,
_rowy_ref
);
return (
<Stack direction="row" id={getFieldId(column.key)}>
<Box sx={fieldSx}>
{documentCount} {column.name as string}: {label}
</Box>
<IconButton
component={Link}
to={subTablePath}
edge="end"
size="small"
sx={{ ml: 1 }}
disabled={!subTablePath}
>
<OpenIcon />
</IconButton>
</Stack>
);
}

View File

@@ -0,0 +1,35 @@
import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import { ArraySubTable as ArraySubTableIcon } from "@src/assets/icons/ArraySubTable";
import DisplayCell from "./DisplayCell";
const SideDrawerField = lazy(
() =>
import(
"./SideDrawerField" /* webpackChunkName: "SideDrawerField-ArraySubTable" */
)
);
const Settings = lazy(
() => import("./Settings" /* webpackChunkName: "Settings-ArraySubTable" */)
);
export const config: IFieldConfig = {
type: FieldType.arraySubTable,
name: "Array SubTable (Alpha)",
group: "Connection",
dataType: "undefined",
initialValue: null,
icon: <ArraySubTableIcon />,
settings: Settings,
description: "A sub-table representing an array of objects in the row",
TableCell: withRenderTableCell(DisplayCell, null, "focus", {
usesRowData: true,
disablePadding: true,
}),
SideDrawerField,
initializable: false,
requireConfiguration: true,
requireCollectionTable: true,
};
export default config;

View File

@@ -0,0 +1,34 @@
import { useLocation } from "react-router-dom";
import { ROUTES } from "@src/constants/routes";
import { ColumnConfig, TableRow, TableRowRef } from "@src/types/table";
export const useSubTableData = (
column: ColumnConfig,
row: TableRow,
_rowy_ref: TableRowRef
) => {
const label = (column.config?.parentLabel ?? []).reduce((acc, curr) => {
if (acc !== "") return `${acc} - ${row[curr]}`;
else return row[curr];
}, "");
const documentCount: string = row[column.fieldName]?.count ?? "";
const location = useLocation();
const rootTablePath = decodeURIComponent(
location.pathname.split("/" + ROUTES.subTable)[0]
);
// Get params from URL: /table/:tableId/arraySubTable/:docPath/:arraySubTableKey
let subTablePath = [
rootTablePath,
ROUTES.arraySubTable,
encodeURIComponent(_rowy_ref.path),
column.key,
].join("/");
subTablePath += "?parentLabel=" + encodeURIComponent(label ?? "");
return { documentCount, label, subTablePath };
};

View File

@@ -5,6 +5,8 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import CheckboxIcon from "@mui/icons-material/ToggleOnOutlined";
import DisplayCell from "./DisplayCell";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const EditorCell = lazy(
() => import("./EditorCell" /* webpackChunkName: "EditorCell-Checkbox" */)
);
@@ -42,5 +44,6 @@ export const config: IFieldConfig = {
defaultValue: false,
},
SideDrawerField,
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -5,6 +5,8 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import CodeIcon from "@mui/icons-material/Code";
import DisplayCell from "./DisplayCell";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const Settings = lazy(
() => import("./Settings" /* webpackChunkName: "Settings-Code" */)
);
@@ -31,5 +33,6 @@ export const config: IFieldConfig = {
}),
SideDrawerField,
settings: Settings,
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -7,6 +7,8 @@ import ColorIcon from "@mui/icons-material/Colorize";
import DisplayCell from "./DisplayCell";
import { filterOperators, valueFormatter } from "./filters";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const EditorCell = lazy(
() => import("./EditorCell" /* webpackChunkName: "EditorCell-Color" */)
);
@@ -41,5 +43,6 @@ export const config: IFieldConfig = {
return null;
}
},
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -11,16 +11,19 @@ export const replacer = (data: any) => (m: string, key: string) => {
return get(data, objKey, defaultValue);
};
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 baseFunction = `// Import any NPM package needed
// import _ from "lodash";
const connector: Connector = async ({ query, row, user, logging }) => {
logging.log("connector started");
// return [
// { id: "a", name: "Apple" },
// { id: "b", name: "Banana" },
// ];
};
export default connector;
`;
export const getLabel = (config: any, row: TableRow) => {
if (!config.labelFormatter) {

View File

@@ -4,6 +4,7 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import { CreatedAt as CreatedAtIcon } from "@src/assets/icons";
import DisplayCell from "./DisplayCell";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -27,5 +28,7 @@ export const config: IFieldConfig = {
TableCell: withRenderTableCell(DisplayCell, null),
SideDrawerField,
settings: Settings,
requireCollectionTable: true,
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -4,6 +4,7 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import { CreatedBy as CreatedByIcon } from "@src/assets/icons";
import DisplayCell from "./DisplayCell";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -28,5 +29,7 @@ export const config: IFieldConfig = {
TableCell: withRenderTableCell(DisplayCell, null),
SideDrawerField,
settings: Settings,
requireCollectionTable: true,
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -7,6 +7,7 @@ import { DATE_FORMAT } from "@src/constants/dates";
import DateIcon from "@mui/icons-material/TodayOutlined";
import DisplayCell from "./DisplayCell";
import { filterOperators, valueFormatter } from "./filters";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const EditorCell = lazy(
() => import("./EditorCell" /* webpackChunkName: "EditorCell-Date" */)
@@ -42,6 +43,7 @@ export const config: IFieldConfig = {
return format(value.toDate(), DATE_FORMAT);
}
},
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -7,6 +7,7 @@ import { DATE_TIME_FORMAT } from "@src/constants/dates";
import DateTimeIcon from "@mui/icons-material/AccessTime";
import DisplayCell from "./DisplayCell";
import { filterOperators, valueFormatter } from "./filters";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const EditorCell = lazy(
() => import("./EditorCell" /* webpackChunkName: "EditorCell-DateTime" */)
@@ -54,6 +55,7 @@ export const config: IFieldConfig = {
return format(value.toDate(), DATE_TIME_FORMAT);
}
},
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -66,27 +66,26 @@ export default function Settings({
? config.derivativeFn
: config?.script
? `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');
: `// Import any NPM package needed
// import _ from "lodash";
const derivative: Derivative = async ({ row, ref, db, storage, auth, logging }) => {
logging.log("derivative started");
// 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
}`;
};
export default derivative;
`;
return (
<>

View File

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

View File

@@ -4,6 +4,7 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import DurationIcon from "@mui/icons-material/TimerOutlined";
import DisplayCell from "./DisplayCell";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -24,5 +25,6 @@ export const config: IFieldConfig = {
popoverProps: { PaperProps: { sx: { p: 1 } } },
}),
SideDrawerField,
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -0,0 +1,83 @@
import { useAtom } from "jotai";
import { find, get } from "lodash-es";
import { useSnackbar } from "notistack";
import OpenIcon from "@mui/icons-material/OpenInNewOutlined";
import { Copy } from "@src/assets/icons";
import { FileIcon } from ".";
import {
tableScope,
tableSchemaAtom,
tableRowsAtom,
} from "@src/atoms/tableScope";
import { IFieldConfig } from "@src/components/fields/types";
export interface IContextMenuActions {
label: string;
icon: React.ReactNode;
onClick: () => void;
}
export const ContextMenuActions: IFieldConfig["contextMenuActions"] = (
selectedCell,
reset
) => {
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableRows] = useAtom(tableRowsAtom, tableScope);
const { enqueueSnackbar } = useSnackbar();
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 isEmpty =
cellValue === "" ||
cellValue === null ||
cellValue === undefined ||
cellValue.length === 0;
const isSingleValue = isEmpty || cellValue?.length === 1;
const handleCopyFileURL = (fileObj: RowyFile) => () => {
navigator.clipboard.writeText(fileObj.downloadURL);
enqueueSnackbar("Copied file URL");
reset();
};
const handleViewFile = (fileObj: RowyFile) => () => {
window.open(fileObj.downloadURL, "_blank");
reset();
};
return [
{
label: "Copy file URL",
icon: <Copy />,
onClick: isSingleValue ? handleCopyFileURL(cellValue[0]) : undefined,
disabled: isEmpty,
subItems: isSingleValue
? []
: cellValue.map((fileObj: RowyFile, index: number) => ({
label: fileObj.name || "File " + (index + 1),
icon: <FileIcon />,
onClick: handleCopyFileURL(fileObj),
})),
},
{
label: "View file",
icon: <OpenIcon />,
onClick: isSingleValue ? handleViewFile(cellValue[0]) : undefined,
disabled: isEmpty,
subItems: isSingleValue
? []
: cellValue.map((fileObj: RowyFile, index: number) => ({
label: fileObj.name || "File " + (index + 1),
icon: <FileIcon />,
onClick: handleViewFile(fileObj),
})),
},
];
};
export default ContextMenuActions;

View File

@@ -4,6 +4,7 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import FileIcon from "@mui/icons-material/AttachFile";
import DisplayCell from "./DisplayCell";
import ContextMenuActions from "./ContextMenuActions";
const EditorCell = lazy(
() => import("./EditorCell" /* webpackChunkName: "EditorCell-File" */)
@@ -26,6 +27,7 @@ export const config: IFieldConfig = {
disablePadding: true,
}),
SideDrawerField,
contextMenuActions: ContextMenuActions,
};
export default config;

View File

@@ -47,7 +47,9 @@ export default function useFileUpload(
async (files: File[]) => {
const { uploads, failures } = await upload({
docRef,
fieldName,
fieldName: docRef.arrayTableData
? `${docRef.arrayTableData?.parentField}/${docRef.arrayTableData?.index}/${fieldName}`
: fieldName,
files,
});
updateField({
@@ -55,6 +57,7 @@ export default function useFileUpload(
fieldName,
value: uploads,
useArrayUnion: true,
arrayTableData: docRef.arrayTableData,
});
return { uploads, failures };
},
@@ -69,10 +72,11 @@ export default function useFileUpload(
value: [file],
useArrayRemove: true,
disableCheckEquality: true,
arrayTableData: docRef.arrayTableData,
});
deleteUpload(file);
},
[deleteUpload, docRef, fieldName, updateField]
[deleteUpload, docRef.arrayTableData, docRef.path, fieldName, updateField]
);
// Drag and Drop

View File

@@ -6,6 +6,7 @@ import { defaultFn, getDisplayCell } from "./util";
export default function Formula(props: IDisplayCellProps) {
const { result, error, loading } = useFormula({
column: props.column,
row: props.row,
ref: props._rowy_ref,
listenerFields: props.column.config?.listenerFields || [],

View File

@@ -0,0 +1,46 @@
import { ISideDrawerFieldProps } from "@src/components/fields/types";
import { IFieldConfig } from "@src/components/fields/types";
import { getFieldProp } from "@src/components/fields";
import { isEmpty } from "lodash-es";
import { createElement } from "react";
export default function Formula({
column,
onChange,
onSubmit,
_rowy_ref,
onDirty,
row,
}: ISideDrawerFieldProps) {
const value = row[`_rowy_formulaValue_${column.key}`];
let type = column.type;
if (column.config && column.config.renderFieldType) {
type = column.config.renderFieldType;
}
const fieldComponent: IFieldConfig["SideDrawerField"] = getFieldProp(
"SideDrawerField",
type
);
// Should not reach this state
if (isEmpty(fieldComponent)) {
console.error("Could not find SideDrawerField component", column);
return null;
}
return (
<>
{createElement(fieldComponent, {
column,
_rowy_ref,
value,
onDirty,
onChange,
onSubmit,
disabled: true,
row,
})}
</>
);
}

View File

@@ -1,9 +1,16 @@
import { lazy } from "react";
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";
const SideDrawerField = lazy(
() =>
import(
"./SideDrawerField" /* webpackChunkName: "SideDrawerField-Formula" */
)
);
export const config: IFieldConfig = {
type: FieldType.formula,
@@ -16,7 +23,7 @@ export const config: IFieldConfig = {
TableCell: withRenderTableCell(DisplayCell as any, null, undefined, {
usesRowData: true,
}),
SideDrawerField: () => null as any,
SideDrawerField,
settings: Settings,
settingsValidator: settingsValidator,
requireConfiguration: true,

View File

@@ -1,9 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { pick, zipObject } from "lodash-es";
import { useAtom } from "jotai";
import { useAtom, useSetAtom } from "jotai";
import { TableRow, TableRowRef } from "@src/types/table";
import { tableColumnsOrderedAtom, tableScope } from "@src/atoms/tableScope";
import { TableRow, TableRowRef, ColumnConfig } from "@src/types/table";
import {
tableColumnsOrderedAtom,
tableScope,
updateFieldAtom,
} from "@src/atoms/tableScope";
import {
listenerFieldTypes,
@@ -12,11 +16,13 @@ import {
} from "./util";
export const useFormula = ({
column,
row,
ref,
listenerFields,
formulaFn,
}: {
column: ColumnConfig;
row: TableRow;
ref: TableRowRef;
listenerFields: string[];
@@ -78,5 +84,14 @@ export const useFormula = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [useDeepCompareMemoize(listeners), formulaFn]);
const updateField = useSetAtom(updateFieldAtom, tableScope);
useEffect(() => {
updateField({
path: row._rowy_ref.path,
fieldName: `_rowy_formulaValue_${column.key}`,
value: result,
});
}, [result, column.key, row._rowy_ref.path, updateField]);
return { result, error, loading };
};

View File

@@ -1,10 +1,10 @@
import { lazy } from "react";
import { GeoPoint } from "firebase/firestore";
import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import GeoPointIcon from "@mui/icons-material/PinDropOutlined";
import DisplayCell from "./DisplayCell";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -15,7 +15,7 @@ const SideDrawerField = lazy(
export const config: IFieldConfig = {
type: FieldType.geoPoint,
name: "GeoPoint (Alpha)",
name: "GeoPoint",
group: "Numeric",
dataType: "{latitude:number; longitude:number}",
initialValue: {},
@@ -25,5 +25,6 @@ export const config: IFieldConfig = {
popoverProps: { PaperProps: { sx: { p: 1, pt: 0 } } },
}),
SideDrawerField,
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -4,6 +4,7 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import { Markdown as MarkdownIcon } from "@src/assets/icons";
import DisplayCell from "./DisplayCell";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -23,5 +24,6 @@ export const config: IFieldConfig = {
description: "Markdown editor with preview",
TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover"),
SideDrawerField,
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -5,6 +5,7 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import { MultiSelect as MultiSelectIcon } from "@src/assets/icons";
import DisplayCell from "./DisplayCell";
import { filterOperators } from "./Filter";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const EditorCell = lazy(
() => import("./EditorCell" /* webpackChunkName: "EditorCell-MultiSelect" */)
@@ -48,5 +49,6 @@ export const config: IFieldConfig = {
filter: {
operators: filterOperators,
},
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -6,6 +6,7 @@ import RatingIcon from "@mui/icons-material/StarBorder";
import DisplayCell from "./DisplayCell";
import EditorCell from "./EditorCell";
import { filterOperators } from "@src/components/fields/Number/Filter";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -44,5 +45,6 @@ export const config: IFieldConfig = {
return null;
}
},
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -6,6 +6,7 @@ import { SingleSelect as SingleSelectIcon } from "@src/assets/icons";
import DisplayCell from "./DisplayCell";
import EditorCell from "./EditorCell";
import { filterOperators } from "@src/components/fields/ShortText/Filter";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -21,7 +22,7 @@ export const config: IFieldConfig = {
type: FieldType.singleSelect,
name: "Single Select",
group: "Select",
dataType: "string | null",
dataType: "string",
initialValue: null,
initializable: true,
icon: <SingleSelectIcon />,
@@ -35,5 +36,6 @@ export const config: IFieldConfig = {
settings: Settings,
filter: { operators: filterOperators },
requireConfiguration: true,
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -5,6 +5,7 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import { Slider as SliderIcon } from "@src/assets/icons";
import DisplayCell from "./DisplayCell";
import { filterOperators } from "@src/components/fields/Number/Filter";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -44,5 +45,6 @@ export const config: IFieldConfig = {
return null;
}
},
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -63,7 +63,7 @@ export default function getLabel(value: any, conditions: any) {
let _label: any = undefined;
const isBoolean = Boolean(typeof value === "boolean");
const notBoolean = Boolean(typeof value !== "boolean");
const isNullOrUndefined = Boolean(!value && notBoolean);
const isNullOrUndefined = Boolean((value === null || value === undefined) && notBoolean);
const isNumeric = Boolean(typeof value === "number");
if (isNullOrUndefined) _label = getFalseyLabelFrom(conditions, value);

View File

@@ -31,5 +31,6 @@ export const config: IFieldConfig = {
SideDrawerField,
initializable: false,
requireConfiguration: true,
requireCollectionTable: true,
};
export default config;

View File

@@ -2,6 +2,7 @@ import { useLocation } from "react-router-dom";
import { ROUTES } from "@src/constants/routes";
import { ColumnConfig, TableRow, TableRowRef } from "@src/types/table";
import get from "lodash-es/get";
export const useSubTableData = (
column: ColumnConfig,
@@ -9,8 +10,8 @@ export const useSubTableData = (
_rowy_ref: TableRowRef
) => {
const label = (column.config?.parentLabel ?? []).reduce((acc, curr) => {
if (acc !== "") return `${acc} - ${row[curr]}`;
else return row[curr];
if (acc !== "") return `${acc} - ${get(row, curr)}`;
else return get(row, curr);
}, "");
const documentCount: string = row[column.fieldName]?.count ?? "";

View File

@@ -4,6 +4,7 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import { UpdatedAt as UpdatedAtIcon } from "@src/assets/icons";
import DisplayCell from "./DisplayCell";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -28,5 +29,7 @@ export const config: IFieldConfig = {
TableCell: withRenderTableCell(DisplayCell, null),
SideDrawerField,
settings: Settings,
requireCollectionTable: true,
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -5,6 +5,8 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import { UpdatedBy as UpdatedByIcon } from "@src/assets/icons";
import DisplayCell from "./DisplayCell";
import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
import(
@@ -29,5 +31,7 @@ export const config: IFieldConfig = {
TableCell: withRenderTableCell(DisplayCell, null),
SideDrawerField,
settings: Settings,
requireCollectionTable: true,
contextMenuActions: BasicContextMenuActions,
};
export default config;

View File

@@ -26,6 +26,7 @@ import Image_ from "./Image";
import File_ from "./File";
import Connector from "./Connector";
import SubTable from "./SubTable";
import ArraySubTable from "./ArraySubTable";
import Reference from "./Reference";
import ConnectTable from "./ConnectTable";
import ConnectService from "./ConnectService";
@@ -75,6 +76,7 @@ export const FIELDS: IFieldConfig[] = [
File_,
/** CONNECTION */
Connector,
ArraySubTable,
SubTable,
Reference,
ConnectTable,

View File

@@ -20,6 +20,7 @@ export interface IFieldConfig {
initializable?: boolean;
requireConfiguration?: boolean;
requireCloudFunction?: boolean;
requireCollectionTable?: boolean;
initialValue: any;
icon?: React.ReactNode;
description?: string;
@@ -80,7 +81,6 @@ export interface ISideDrawerFieldProps<T = any> {
column: ColumnConfig;
/** The rows _rowy_ref object */
_rowy_ref: TableRowRef;
/** The fields local value  synced with db when field is not dirty */
value: T;
/** Call when the user has input but changes have not been saved */
@@ -92,6 +92,8 @@ export interface ISideDrawerFieldProps<T = any> {
/** Field locked. Do NOT check `column.locked` */
disabled: boolean;
row: TableRow
}
export interface ISettingsProps {

View File

@@ -28,6 +28,7 @@ export enum FieldType {
// CONNECTION
connector = "CONNECTOR",
subTable = "SUB_TABLE",
arraySubTable = "ARRAY_SUB_TABLE",
reference = "REFERENCE",
connectTable = "DOCUMENT_SELECT",
connectService = "SERVICE_SELECT",

View File

@@ -25,8 +25,10 @@ export enum ROUTES {
tableWithId = "/table/:id",
/** Nested route: `/table/:id/subTable/...` */
subTable = "subTable",
arraySubTable = "arraySubTable",
/** Nested route: `/table/:id/subTable/...` */
subTableWithId = "subTable/:docPath/:subTableKey",
arraySubTableWithId = "arraySubTable/:docPath/:subTableKey",
/** @deprecated Redirects to /table */
tableGroup = "/tableGroup",
/** @deprecated Redirects to /table */

View File

@@ -127,14 +127,11 @@ const useFirebaseStorageUploader = () => {
<Paper elevation={0} sx={{ borderRadius: 1 }}>
<Button
color="primary"
href={
WIKI_LINKS.setupRoles +
"#write-firebase-storage-security-rules"
}
href={WIKI_LINKS.faqsAccess + "#unable-to-upload-files"}
target="_blank"
rel="noopener noreferrer"
>
Fix
Learn More
<InlineOpenInNewIcon />
</Button>
</Paper>

View File

@@ -0,0 +1,480 @@
import { useCallback, useEffect } from "react";
import useMemoValue from "use-memo-value";
import { useAtom, PrimitiveAtom, useSetAtom } from "jotai";
import { orderBy } from "lodash-es";
import { useSnackbar } from "notistack";
import {
Firestore,
doc,
refEqual,
onSnapshot,
FirestoreError,
DocumentReference,
runTransaction,
} from "firebase/firestore";
import { useErrorHandler } from "react-error-boundary";
import { projectScope } from "@src/atoms/projectScope";
import {
ArrayTableRowData,
DeleteCollectionDocFunction,
TableRow,
TableSort,
UpdateCollectionDocFunction,
} from "@src/types/table";
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
import { omitRowyFields } from "@src/utils/table";
type UpdateFunction<T> = (rows: T[]) => T[];
/** Options for {@link useFirestoreDocWithAtom} */
interface IUseFirestoreDocWithAtomOptions<T> {
/** Called when an error occurs. Make sure to wrap in useCallback! If not provided, errors trigger the nearest ErrorBoundary. */
onError?: (error: FirestoreError) => void;
/** Optionally disable Suspense */
disableSuspense?: boolean;
/** Optionally create the document if it doesnt exist with the following data */
createIfNonExistent?: T;
/** Set this atoms value to a function that updates the document. Uses same scope as `dataScope`. */
// updateDataAtom?: PrimitiveAtom<UpdateDocFunction<T> | undefined>;
updateDocAtom?: PrimitiveAtom<UpdateCollectionDocFunction<T> | undefined>;
deleteDocAtom?: PrimitiveAtom<DeleteCollectionDocFunction | undefined>;
sorts?: TableSort[];
}
/**
* Attaches a listener for a Firestore document and unsubscribes on unmount.
* Gets the Firestore instance initiated in projectScope.
* Updates an atom and Suspends that atom until the first snapshot is received.
*
* @param dataAtom - Atom to store data in
* @param dataScope - Atom scope
* @param path - Document path. If falsy, the listener isnt created at all.
* @param fieldName - Parent field name
* @param options - {@link IUseFirestoreDocWithAtomOptions}
*/
export function useFirestoreDocAsCollectionWithAtom<T = TableRow>(
dataAtom: PrimitiveAtom<T[]>,
dataScope: Parameters<typeof useAtom>[1] | undefined,
path: string,
fieldName: string,
options: IUseFirestoreDocWithAtomOptions<T>
) {
// Destructure options so they can be used as useEffect dependencies
const {
onError,
disableSuspense,
createIfNonExistent,
updateDocAtom,
deleteDocAtom,
sorts,
} = options || {};
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
const setDataAtom = useSetAtom(dataAtom, dataScope);
const { addRow, deleteRow, deleteField, updateTable } = useAlterArrayTable<T>(
{
firebaseDb,
dataAtom,
dataScope,
sorts,
path,
fieldName,
}
);
const handleError = useErrorHandler();
const { enqueueSnackbar } = useSnackbar();
const setUpdateDocAtom = useSetAtom(
updateDocAtom || (dataAtom as any),
dataScope
);
const setDeleteRowAtom = useSetAtom(
deleteDocAtom || (dataAtom as any),
dataScope
);
// Create the doc ref and memoize using Firestores refEqual
const memoizedDocRef = useMemoValue(
getDocRef<T>(firebaseDb, path),
(next, prev) => refEqual(next as any, prev as any)
);
useEffect(() => {
// If path is invalid and no memoizedDocRef was created, dont continue
if (!memoizedDocRef) return;
// Suspend data atom until we get the first snapshot
let suspended = false;
if (!disableSuspense) {
setDataAtom(new Promise(() => []) as unknown as T[]);
suspended = true;
}
// Create a listener for the document
const unsubscribe = onSnapshot(
memoizedDocRef,
{ includeMetadataChanges: true },
(docSnapshot) => {
try {
if (docSnapshot.exists() && docSnapshot.data() !== undefined) {
const pseudoDoc = docSnapshot.get(fieldName) || [];
const pseudoRow = pseudoDoc.map((row: any, i: number) => {
return {
...row,
_rowy_ref: {
path: docSnapshot.ref.path,
id: docSnapshot.ref.id,
arrayTableData: {
index: i,
parentField: fieldName,
},
},
};
});
const sorted = sortRows<T>(pseudoRow, sorts);
setDataAtom(sorted);
} else {
enqueueSnackbar(`Array table doesn't exist`, {
variant: "error",
});
// console.log("docSnapshot", docSnapshot.data());
// setDataAtom([] as T[]);
}
} catch (error) {
if (onError) onError(error as FirestoreError);
else handleError(error);
}
suspended = false;
},
(error) => {
if (suspended) setDataAtom([] as T[]);
if (onError) onError(error);
else handleError(error);
}
);
// When the listener will change, unsubscribe
return () => {
unsubscribe();
};
}, [
memoizedDocRef,
onError,
setDataAtom,
disableSuspense,
createIfNonExistent,
handleError,
fieldName,
sorts,
enqueueSnackbar,
]);
const setRows = useCallback(
(updateFunction: UpdateFunction<T>) => {
if (!fieldName) return;
try {
return runTransaction(firebaseDb, async (transaction) => {
const docRef = doc(firebaseDb, path);
const docSnap = await transaction.get(docRef);
const rows = docSnap.data()?.[fieldName] || [];
const updatedRows = updateFunction(rows);
return await transaction.set(
docRef,
{ [fieldName]: updatedRows },
{ merge: true }
);
});
} catch (error) {
enqueueSnackbar(`Error updating array table`, {
variant: "error",
});
return;
}
},
[enqueueSnackbar, fieldName, firebaseDb, path]
);
useEffect(() => {
if (deleteDocAtom) {
setDeleteRowAtom(() => (_: string, options?: ArrayTableRowData) => {
if (!options || options.index === undefined) return;
const updateFunction = deleteRow(options.index);
return setRows(updateFunction);
});
}
}, [
deleteDocAtom,
deleteRow,
fieldName,
firebaseDb,
path,
setDataAtom,
setDeleteRowAtom,
setRows,
sorts,
]);
useEffect(() => {
if (updateDocAtom) {
setUpdateDocAtom(
() =>
(
_: string,
update: T,
deleteFields?: string[],
options?: ArrayTableRowData
) => {
if (options === undefined) return;
const deleteRowFields = () => {
if (options.index === undefined) return;
const updateFunction = deleteField(options.index, deleteFields);
return setRows(updateFunction);
};
const updateRowValues = () => {
if (options.index === undefined) return;
const updateFunction = updateTable(options.index, update);
return setRows(updateFunction);
};
const addNewRow = (addTo: "top" | "bottom", base?: T) => {
const updateFunction = addRow(addTo, base ?? update);
return setRows(updateFunction);
};
if (Array.isArray(deleteFields) && deleteFields.length > 0) {
return deleteRowFields();
} else if (options.operation?.addRow) {
return addNewRow(
options.operation.addRow,
options?.operation.base as T
);
} else {
return updateRowValues();
}
}
);
}
}, [
addRow,
deleteField,
fieldName,
firebaseDb,
path,
setDataAtom,
setRows,
setUpdateDocAtom,
sorts,
updateDocAtom,
updateTable,
]);
}
export default useFirestoreDocAsCollectionWithAtom;
function useAlterArrayTable<T>({
firebaseDb,
dataAtom,
dataScope,
sorts,
path,
fieldName,
}: {
firebaseDb: Firestore;
dataAtom: PrimitiveAtom<T[]>;
dataScope: Parameters<typeof useAtom>[1] | undefined;
sorts: TableSort[] | undefined;
path: string;
fieldName: string;
}) {
const setData = useSetAtom(dataAtom, dataScope);
const add = useCallback(
(addTo: "top" | "bottom", base?: T): UpdateFunction<T> => {
if (base) {
base = omitRowyFields(base);
}
const newRow = (i: number, meta: boolean) => {
const _meta = {
_rowy_ref: {
id: doc(firebaseDb, path).id,
path: doc(firebaseDb, path).path,
arrayTableData: {
index: i,
parentField: fieldName,
},
},
};
if (meta === true) {
return {
...(base ?? {}),
..._meta,
} as T;
}
return {
...(base ?? {}),
} as T;
};
setData((prevData) => {
prevData = unsortRows(prevData);
if (addTo === "bottom") {
prevData.push(newRow(prevData.length, true));
} else {
const modifiedPrevData = prevData.map((row: any, i: number) => {
return {
...row,
_rowy_ref: {
...row._rowy_ref,
arrayTableData: {
index: i + 1,
parentField: fieldName,
},
},
};
});
prevData = [newRow(0, true), ...modifiedPrevData];
}
return sortRows(prevData, sorts);
});
return (rows) => {
if (addTo === "bottom") {
rows.push(newRow(rows.length, false));
} else {
rows = [newRow(0, false), ...rows];
}
return rows;
};
},
[fieldName, firebaseDb, path, setData, sorts]
);
const _delete = useCallback(
(index: number): UpdateFunction<T> => {
setData((prevData) => {
prevData = unsortRows<T>(prevData);
prevData.splice(index, 1);
for (let i = index; i < prevData.length; i++) {
// @ts-ignore
prevData[i]._rowy_ref.arrayTableData.index = i;
}
return sortRows(prevData, sorts);
});
return (rows) => {
rows.splice(index, 1);
return [...rows];
};
},
[setData, sorts]
);
const deleteField = useCallback(
(index: number, deleteFields?: string[]): UpdateFunction<T> => {
setData((prevData) => {
prevData = unsortRows(prevData);
if (deleteFields === undefined) return prevData;
prevData[index] = {
...prevData[index],
...deleteFields?.reduce(
(acc, field) => ({ ...acc, [field]: undefined }),
{}
),
};
return sortRows(prevData, sorts);
});
return (rows) => {
if (deleteFields === undefined) return rows;
rows[index] = {
...rows[index],
...deleteFields?.reduce(
(acc, field) => ({ ...acc, [field]: undefined }),
{}
),
};
return rows;
};
},
[setData, sorts]
);
const update = useCallback(
(index: number, update: Partial<T>): UpdateFunction<T> => {
setData((prevData) => {
prevData = unsortRows(prevData);
prevData[index] = {
...prevData[index],
...update,
};
return sortRows(prevData, sorts);
});
return (rows) => {
rows[index] = {
...rows[index],
...update,
};
return rows;
};
},
[setData, sorts]
);
return {
addRow: add,
deleteRow: _delete,
deleteField: deleteField,
updateTable: update,
};
}
/**
* Create the Firestore document reference.
* Put code in a function so the results can be compared by useMemoValue.
*/
export const getDocRef = <T>(
firebaseDb: Firestore,
path: string | undefined,
pathSegments?: Array<string | undefined>
) => {
if (!path || (Array.isArray(pathSegments) && pathSegments?.some((x) => !x)))
return null;
return doc(
firebaseDb,
path,
...((pathSegments as string[]) || [])
) as DocumentReference<T>;
};
function sortRows<T = TableRow>(
rows: T[],
sorts: TableSort[] | undefined
): T[] {
if (sorts === undefined || sorts.length < 1) {
return rows;
}
const order: "asc" | "desc" =
sorts[0].direction === undefined ? "asc" : sorts[0].direction;
return orderBy(rows, [sorts[0].key], [order]);
}
function unsortRows<T = TableRow>(rows: T[]): T[] {
return orderBy(rows, ["_rowy_ref.arrayTableData.index"], ["asc"]);
}

View File

@@ -0,0 +1,155 @@
import { lazy, Suspense, useMemo } from "react";
import { useAtom, Provider } from "jotai";
import { selectAtom } from "jotai/utils";
import { DebugAtoms } from "@src/atoms/utils";
import { ErrorBoundary } from "react-error-boundary";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { find, isEqual } from "lodash-es";
import Modal from "@src/components/Modal";
import BreadcrumbsSubTable from "@src/components/Table/Breadcrumbs/BreadcrumbsSubTable";
import ErrorFallback from "@src/components/ErrorFallback";
import ArraySubTableSourceFirestore from "@src/sources/TableSourceFirestore/ArraySubTableSourceFirestore";
import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton";
import TableSkeleton from "@src/components/Table/TableSkeleton";
import { projectScope, currentUserAtom } from "@src/atoms/projectScope";
import {
tableScope,
tableIdAtom,
tableSettingsAtom,
tableSchemaAtom,
} from "@src/atoms/tableScope";
import { ROUTES } from "@src/constants/routes";
import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar";
import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar";
// prettier-ignore
const TablePage = lazy(() => import("./TablePage" /* webpackChunkName: "TablePage" */));
/**
* Wraps `TablePage` with the data for a array-sub-table.
*
* Differences to `ProvidedTablePage`:
* - Renders a `Modal`
* - When this is a child of `ProvidedTablePage`, the `TablePage` rendered for
* the root table has its modals disabled
*/
export default function ProvidedArraySubTablePage() {
const location = useLocation();
const navigate = useNavigate();
// Get params from URL: /arraySubTable/:docPath/:subTableKey
const { docPath, subTableKey } = useParams();
const [currentUser] = useAtom(currentUserAtom, projectScope);
// Get table settings and the source column from root table
const [rootTableSettings] = useAtom(tableSettingsAtom, tableScope);
const [sourceColumn] = useAtom(
useMemo(
() =>
selectAtom(
tableSchemaAtom,
(tableSchema) => find(tableSchema.columns, ["key", subTableKey]),
isEqual
),
[subTableKey]
),
tableScope
);
// Consumed by children as `tableSettings.collection`
const subTableCollection = docPath ?? ""; // + "/" + (sourceColumn?.fieldName || subTableKey);
// Must be compatible with `getTableSchemaPath`: tableId/rowId/subTableKey
// This is why we cant have a sub-table column fieldName !== key
const subTableId =
docPath?.replace(rootTableSettings.collection, rootTableSettings.id) +
"/" +
subTableKey;
// Write fake tableSettings
const subTableSettings = {
...rootTableSettings,
collection: subTableCollection,
id: subTableId,
subTableKey,
isCollection: false,
tableType: "primaryCollection" as "primaryCollection",
name: sourceColumn?.name || subTableKey || "",
};
const rootTableLink = location.pathname.split("/" + ROUTES.arraySubTable)[0];
return (
<Modal
title={
<BreadcrumbsSubTable
rootTableSettings={rootTableSettings}
subTableSettings={subTableSettings}
rootTableLink={rootTableLink}
/>
}
onClose={() => navigate(rootTableLink)}
disableBackdropClick
disableEscapeKeyDown
fullScreen
sx={{
"& > .MuiDialog-container > .MuiPaper-root": {
bgcolor: "background.default",
backgroundImage: "none",
},
"& .modal-title-row": {
height: TOP_BAR_HEIGHT,
"& .MuiDialogTitle-root": {
px: 2,
py: (TOP_BAR_HEIGHT - 28) / 2 / 8,
},
"& .dialog-close": { m: (TOP_BAR_HEIGHT - 40) / 2 / 8, ml: -1 },
},
"& .table-container": {
height: `calc(100vh - ${TOP_BAR_HEIGHT}px - ${TABLE_TOOLBAR_HEIGHT}px - 16px)`,
},
}}
ScrollableDialogContentProps={{
disableTopDivider: true,
disableBottomDivider: true,
style: { "--dialog-spacing": 0, "--dialog-contents-spacing": 0 } as any,
}}
BackdropProps={{ key: "sub-table-modal-backdrop" }}
>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense
fallback={
<>
<TableToolbarSkeleton />
<TableSkeleton />
</>
}
>
<Provider
key={tableScope.description + "/subTable/" + subTableSettings.id}
scope={tableScope}
initialValues={[
[currentUserAtom, currentUser],
[tableIdAtom, subTableSettings.id],
[tableSettingsAtom, subTableSettings],
]}
>
<DebugAtoms scope={tableScope} />
<ArraySubTableSourceFirestore />
<TablePage
disabledTools={[
"import",
"export",
"webhooks",
"extensions",
"cloud_logs",
]}
/>
</Provider>
</Suspense>
</ErrorBoundary>
</Modal>
);
}

View File

@@ -42,6 +42,7 @@ import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar";
import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar";
import { DRAWER_COLLAPSED_WIDTH } from "@src/components/SideDrawer";
import { formatSubTableName } from "@src/utils/table";
import { TableToolsType } from "@src/types/table";
// prettier-ignore
const BuildLogsSnack = lazy(() => import("@src/components/TableModals/CloudLogsModal/BuildLogs/BuildLogsSnack" /* webpackChunkName: "TableModals-BuildLogsSnack" */));
@@ -54,6 +55,8 @@ export interface ITablePageProps {
disableModals?: boolean;
/** Disable side drawer */
disableSideDrawer?: boolean;
/** list of table tools to be disabled */
disabledTools?: TableToolsType;
}
/**
@@ -72,6 +75,7 @@ export interface ITablePageProps {
export default function TablePage({
disableModals,
disableSideDrawer,
disabledTools,
}: ITablePageProps) {
const [userRoles] = useAtom(userRolesAtom, projectScope);
const [userSettings] = useAtom(userSettingsAtom, projectScope);
@@ -128,7 +132,7 @@ export default function TablePage({
<ActionParamsProvider>
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
<Suspense fallback={<TableToolbarSkeleton />}>
<TableToolbar />
<TableToolbar disabledTools={disabledTools} />
</Suspense>
</ErrorBoundary>

View File

@@ -22,6 +22,10 @@ import {
AdditionalTableSettings,
MinimumTableSettings,
currentUserAtom,
updateSecretNamesAtom,
projectIdAtom,
rowyRunAtom,
secretNamesAtom,
} from "@src/atoms/projectScope";
import { firebaseDbAtom } from "./init";
@@ -34,10 +38,15 @@ import { rowyUser } from "@src/utils/table";
import { TableSettings, TableSchema, SubTablesSchema } from "@src/types/table";
import { FieldType } from "@src/constants/fields";
import { getFieldProp } from "@src/components/fields";
import { runRoutes } from "@src/constants/runRoutes";
export function useTableFunctions() {
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
const [currentUser] = useAtom(currentUserAtom, projectScope);
const [projectId] = useAtom(projectIdAtom, projectScope);
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
const [secretNames, setSecretNames] = useAtom(secretNamesAtom, projectScope);
const [updateSecretNames] = useAtom(updateSecretNamesAtom, projectScope);
// Create a function to get the latest tables from project settings,
// so we dont create new functions when tables change
@@ -330,4 +339,36 @@ export function useTableFunctions() {
return tableSchema as TableSchema;
});
}, [firebaseDb, readTables, setGetTableSchema]);
// Set the deleteTable function
const setUpdateSecretNames = useSetAtom(updateSecretNamesAtom, projectScope);
useEffect(() => {
if (!projectId || !rowyRun || !secretNamesAtom) return;
setUpdateSecretNames(() => async (clearSecretNames?: boolean) => {
setSecretNames({
loading: true,
secretNames: clearSecretNames ? null : secretNames.secretNames,
});
rowyRun({
route: runRoutes.listSecrets,
})
.then((secrets: string[]) => {
setSecretNames({
loading: false,
secretNames: secrets,
});
})
.catch((e) => {
setSecretNames({
loading: false,
secretNames: clearSecretNames ? null : secretNames.secretNames,
});
});
});
}, [projectId, rowyRun, setUpdateSecretNames]);
useEffect(() => {
if (updateSecretNames) {
updateSecretNames(true);
}
}, [updateSecretNames]);
}

View File

@@ -0,0 +1,143 @@
import { memo, useCallback, useEffect } from "react";
import { useAtom, useSetAtom } from "jotai";
import useMemoValue from "use-memo-value";
import { cloneDeep, set } from "lodash-es";
import {
FirestoreError,
deleteField,
refEqual,
setDoc,
} from "firebase/firestore";
import { useSnackbar } from "notistack";
import { useErrorHandler } from "react-error-boundary";
import {
tableScope,
tableSettingsAtom,
tableSchemaAtom,
updateTableSchemaAtom,
tableSortsAtom,
tableRowsDbAtom,
_updateRowDbAtom,
_deleteRowDbAtom,
tableNextPageAtom,
} from "@src/atoms/tableScope";
import useFirestoreDocWithAtom, {
getDocRef,
} from "@src/hooks/useFirestoreDocWithAtom";
import useAuditChange from "./useAuditChange";
import useBulkWriteDb from "./useBulkWriteDb";
import { handleFirestoreError } from "./handleFirestoreError";
import { getTableSchemaPath } from "@src/utils/table";
import { TableRow, TableSchema } from "@src/types/table";
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
import { projectScope } from "@src/atoms/projectScope";
import useFirestoreDocAsCollectionWithAtom from "@src/hooks/useFirestoreDocAsCollectionWithAtom";
/**
* When rendered, provides atom values for top-level tables and sub-tables
*/
export const TableSourceFirestore2 = memo(function TableSourceFirestore() {
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const setTableSchema = useSetAtom(tableSchemaAtom, tableScope);
const setUpdateTableSchema = useSetAtom(updateTableSchemaAtom, tableScope);
const setTableNextPage = useSetAtom(tableNextPageAtom, tableScope);
const { enqueueSnackbar } = useSnackbar();
if (!tableSettings) throw new Error("No table config");
if (!tableSettings.collection)
throw new Error("Invalid table config: no collection");
const tableSchemaDocRef = useMemoValue(
getDocRef<TableSchema>(firebaseDb, getTableSchemaPath(tableSettings)),
(next, prev) => refEqual(next as any, prev as any)
);
setTableNextPage({
loading: false,
available: false,
});
useEffect(() => {
if (!tableSchemaDocRef) return;
setUpdateTableSchema(
() => (update: TableSchema, deleteFields?: string[]) => {
const updateToDb = cloneDeep(update);
if (Array.isArray(deleteFields)) {
for (const field of deleteFields) {
// Use deterministic set firestore sentinel's on schema columns config
// Required for nested columns
// i.e field = "columns.base.nested.nested"
// key: columns, rest: base.nested.nested
// set columns["base.nested.nested"] instead columns.base.nested.nested
const [key, ...rest] = field.split(".");
if (key === "columns") {
(updateToDb as any).columns[rest.join(".")] = deleteField();
} else {
set(updateToDb, field, deleteField());
}
}
}
// Update UI state to reflect changes immediately to prevent flickering effects
setTableSchema((tableSchema) => ({ ...tableSchema, ...update }));
return setDoc(tableSchemaDocRef, updateToDb, { merge: true }).catch(
(e) => {
enqueueSnackbar((e as Error).message, { variant: "error" });
}
);
}
);
return () => {
setUpdateTableSchema(undefined);
};
}, [tableSchemaDocRef, setTableSchema, setUpdateTableSchema, enqueueSnackbar]);
// Get tableSchema and store in tableSchemaAtom.
// If it doesnt exist, initialize columns
useFirestoreDocWithAtom(
tableSchemaAtom,
tableScope,
getTableSchemaPath(tableSettings),
{
createIfNonExistent: { columns: {} },
disableSuspense: true,
}
);
// Get table sorts
const [sorts] = useAtom(tableSortsAtom, tableScope);
// Get documents from collection and store in tableRowsDbAtom
// and handle some errors with snackbars
const elevateError = useErrorHandler();
const handleErrorCallback = useCallback(
(error: FirestoreError) =>
handleFirestoreError(error, enqueueSnackbar, elevateError),
[enqueueSnackbar, elevateError]
);
useFirestoreDocAsCollectionWithAtom<TableRow>(
tableRowsDbAtom,
tableScope,
tableSettings.collection,
tableSettings.subTableKey || "",
{
sorts,
onError: handleErrorCallback,
updateDocAtom: _updateRowDbAtom,
deleteDocAtom: _deleteRowDbAtom,
}
);
useAuditChange();
useBulkWriteDb();
return null;
});
export default TableSourceFirestore2;

31
src/types/table.d.ts vendored
View File

@@ -31,7 +31,8 @@ export type UpdateDocFunction<T = TableRow> = (
export type UpdateCollectionDocFunction<T = TableRow> = (
path: string,
update: Partial<T>,
deleteFields?: string[]
deleteFields?: string[],
options?: ArrayTableRowData
) => Promise<void>;
/**
@@ -39,7 +40,10 @@ export type UpdateCollectionDocFunction<T = TableRow> = (
* @param path - The full path to the doc
* @returns Promise
*/
export type DeleteCollectionDocFunction = (path: string) => Promise<void>;
export type DeleteCollectionDocFunction = (
path: string,
options?: ArrayTableRowData
) => Promise<void>;
export type BulkWriteOperation<T> =
| { type: "delete"; path: string }
@@ -71,6 +75,8 @@ export type TableSettings = {
/** Roles that can see this table in the UI and navigate. Firestore Rules need to be set to give access to the data */
roles: string[];
isCollection?: boolean;
subTableKey?: string | undefined;
section: string;
description?: string;
details?: string;
@@ -191,16 +197,37 @@ export type TableFilter = {
value: any;
};
export const TableTools = [
"import",
"export",
"webhooks",
"extensions",
"cloud_logs",
] as const;
export type TableToolsType = typeof Tools[number];
export type TableSort = {
key: string;
direction: Parameters<typeof orderBy>[1];
};
export type ArrayTableRowData = {
index?: number;
parentField?: string;
operation?: ArrayTableOperations;
};
export type TableRowRef = {
id: string;
path: string;
arrayTableData?: ArrayTableRowData;
} & Partial<DocumentReference>;
type ArrayTableOperations = {
addRow?: "top" | "bottom";
base?: TableRow;
};
export type TableRow = DocumentData & {
_rowy_ref: TableRowRef;
_rowy_missingRequiredFields?: string[];

View File

@@ -52,6 +52,12 @@ export const omitRowyFields = <T = Record<string, any>>(row: T) => {
delete shallowClonedRow["_rowy_missingRequiredFields"];
delete shallowClonedRow["_rowy_new"];
Object.keys(shallowClonedRow).forEach((key) => {
if (key.startsWith("_rowy_formulaValue_")) {
delete shallowClonedRow[key];
}
});
return shallowClonedRow as T;
};