mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-28 16:06:41 +01:00
Merge branch 'develop' into ui-bug-fixes
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
13
README.md
13
README.md
@@ -13,12 +13,12 @@ Low-code for Firebase and Google Cloud.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://discord.gg/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>
|
||||
|
||||
22
src/App.tsx
22
src/App.tsx
@@ -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>
|
||||
|
||||
|
||||
9
src/assets/icons/ArraySubTable.tsx
Normal file
9
src/assets/icons/ArraySubTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 doesn’t 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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 cell’s 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 cell’s value cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: handleClearValue,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onClick: handleClearValue,
|
||||
},
|
||||
{
|
||||
label: "Filter value",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
{}
|
||||
</>
|
||||
|
||||
@@ -119,12 +119,6 @@ export interface IWebhook {
|
||||
auth?: any;
|
||||
}
|
||||
|
||||
export interface ISecret {
|
||||
loading: boolean;
|
||||
keys: string[];
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const webhookSchemas = {
|
||||
basic,
|
||||
typeform,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -31,6 +31,7 @@ export const config: IFieldConfig = {
|
||||
settings: Settings,
|
||||
requireConfiguration: true,
|
||||
requireCloudFunction: true,
|
||||
requireCollectionTable: true,
|
||||
sortKey: "status",
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
46
src/components/fields/ArraySubTable/DisplayCell.tsx
Normal file
46
src/components/fields/ArraySubTable/DisplayCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/components/fields/ArraySubTable/Settings.tsx
Normal file
32
src/components/fields/ArraySubTable/Settings.tsx
Normal 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;
|
||||
56
src/components/fields/ArraySubTable/SideDrawerField.tsx
Normal file
56
src/components/fields/ArraySubTable/SideDrawerField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/fields/ArraySubTable/index.tsx
Normal file
35
src/components/fields/ArraySubTable/index.tsx
Normal 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;
|
||||
34
src/components/fields/ArraySubTable/utils.ts
Normal file
34
src/components/fields/ArraySubTable/utils.ts
Normal 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 };
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -22,5 +22,6 @@ export const config: IFieldConfig = {
|
||||
settingsValidator,
|
||||
requireConfiguration: true,
|
||||
requireCloudFunction: true,
|
||||
requireCollectionTable: true,
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -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;
|
||||
|
||||
83
src/components/fields/File/ContextMenuActions.tsx
Normal file
83
src/components/fields/File/ContextMenuActions.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
46
src/components/fields/Formula/SideDrawerField.tsx
Normal file
46
src/components/fields/Formula/SideDrawerField.tsx
Normal 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,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -31,5 +31,6 @@ export const config: IFieldConfig = {
|
||||
SideDrawerField,
|
||||
initializable: false,
|
||||
requireConfiguration: true,
|
||||
requireCollectionTable: true,
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 row’s _rowy_ref object */
|
||||
_rowy_ref: TableRowRef;
|
||||
|
||||
/** The field’s 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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
480
src/hooks/useFirestoreDocAsCollectionWithAtom.ts
Normal file
480
src/hooks/useFirestoreDocAsCollectionWithAtom.ts
Normal 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 doesn’t exist with the following data */
|
||||
createIfNonExistent?: T;
|
||||
/** Set this atom’s 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 isn’t 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 Firestore’s 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, don’t 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"]);
|
||||
}
|
||||
155
src/pages/Table/ProvidedArraySubTablePage.tsx
Normal file
155
src/pages/Table/ProvidedArraySubTablePage.tsx
Normal 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 can’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 don’t 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]);
|
||||
}
|
||||
|
||||
@@ -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 doesn’t 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
31
src/types/table.d.ts
vendored
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user