enable TableSettingsDialog

This commit is contained in:
Sidney Alcantara
2022-05-09 16:01:56 +10:00
parent 9f34dd80b1
commit 3ba5fef72f
20 changed files with 872 additions and 270 deletions

View File

@@ -8,7 +8,9 @@ import {
UpdateDocFunction,
UpdateCollectionFunction,
TableSettings,
TableSchema,
} from "@src/types/table";
import { FieldType } from "@src/constants/fields";
export const projectIdAtom = atom<string>("");
@@ -75,6 +77,54 @@ export const tablesAtom = atom<TableSettings[]>((get) => {
}));
});
/**
* Additional table settings that can be passed to write functions
* but are not written to the settings document
*/
export type AdditionalTableSettings = Partial<{
_schemaSource: string;
_initialColumns: Record<FieldType, boolean>;
_schema: TableSchema;
_suggestedRules: string;
}>;
/** Stores a function to create a table with schema doc */
export const createTableAtom = atom<
| ((
settings: TableSettings,
additionalSettings?: AdditionalTableSettings
) => Promise<void>)
| null
>(null);
/**
* Minimum amount of table settings required to be passed to updateTable to
* idetify the table and schema doc
*/
export type MinimumTableSettings = {
id: TableSettings["id"];
tableType: TableSettings["tableType"];
} & Partial<TableSettings>;
/** Stores a function to update a table and its schema doc */
export const updateTableAtom = atom<
| ((
settings: MinimumTableSettings,
additionalSettings?: AdditionalTableSettings
) => Promise<void>)
| null
>(null);
/** Stores a function to delete a table and its schema doc */
export const deleteTableAtom = atom<((id: string) => Promise<void>) | null>(
null
);
/** Stores a function to get a tables schema doc (without listener) */
export const getTableSchemaAtom = atom<
((id: string) => Promise<TableSchema>) | null
>(null);
/** Roles used in the project based on table settings */
export const rolesAtom = atom((get) =>
Array.from(

View File

@@ -2,10 +2,11 @@ import { atom } from "jotai";
import { selectAtom, atomWithStorage } from "jotai/utils";
import { isEqual } from "lodash-es";
import { getIdTokenResult } from "firebase/auth";
import { compare } from "compare-versions";
import { projectSettingsAtom } from "./project";
import { currentUserAtom } from "./auth";
import { RunRoute } from "@src/constants/runRoutes";
import { RunRoute, runRoutes } from "@src/constants/runRoutes";
import meta from "@root/package.json";
/**
@@ -114,6 +115,34 @@ export const rowyRunAtom = atom((get) => {
};
});
/** Store deployed Rowy Run version */
export const rowyRunVersionAtom = atom(async (get) => {
const rowyRun = get(rowyRunAtom);
const response = await rowyRun({ route: runRoutes.version });
return response.version as string | false;
});
/**
* Helper function to check if deployed Rowy Run version
* is compatible with a feature
*/
export const compatibleRowyRunVersionAtom = atom((get) => {
const deployedVersion = get(rowyRunVersionAtom);
return ({
minVersion,
maxVersion,
}: {
minVersion?: string;
maxVersion?: string;
}) => {
if (!deployedVersion) return false;
if (minVersion && compare(deployedVersion, minVersion, "<")) return false;
if (maxVersion && compare(deployedVersion, maxVersion, ">")) return false;
return true;
};
});
type RowyRunLatestUpdate = {
lastChecked: string;
rowy: null | Record<string, any>;

View File

@@ -2,7 +2,8 @@ import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { DialogProps, ButtonProps } from "@mui/material";
import { TableSettings } from "@src/types/table";
import { TableSettings, TableSchema } from "@src/types/table";
import { getTableSchemaAtom } from "./project";
/** Nav open state stored in local storage. */
export const navOpenAtom = atomWithStorage("__ROWY__NAV_OPEN", false);
@@ -124,3 +125,11 @@ export const tableSettingsDialogAtom = atom(
});
}
);
export const tableSettingsDialogIdAtom = atom("");
export const tableSettingsDialogSchemaAtom = atom(async (get) => {
const tableId = get(tableSettingsDialogIdAtom);
const getTableSchema = get(getTableSchemaAtom);
if (!tableId || !getTableSchema) return {} as TableSchema;
return getTableSchema(tableId);
});

View File

@@ -94,7 +94,7 @@ export default function SteppedAccordion({
>
<StepLabel error={error} {...labelProps}>
{title}
<ExpandIcon />
<ExpandIcon sx={{ mr: -0.5 }} />
</StepLabel>
</StepButton>

View File

@@ -1,15 +1,20 @@
import { useState } from "react";
import { useState, Suspense } from "react";
import { Control } from "react-hook-form";
import { useSetAtom } from "jotai";
import type { UseFormReturn, FieldValues } from "react-hook-form";
import { IconButton, Menu } from "@mui/material";
import { IconButton, Menu, MenuItem } from "@mui/material";
import ExportIcon from "assets/icons/Export";
import ImportIcon from "assets/icons/Import";
import ImportSettings from "./ImportSettings";
import ExportSettings from "./ExportSettings";
import { TableSettingsDialogState } from "@src/atoms/globalScope";
import {
globalScope,
tableSettingsDialogIdAtom,
TableSettingsDialogState,
} from "@src/atoms/globalScope";
export interface IActionsMenuProps {
mode: TableSettingsDialogState["mode"];
@@ -24,7 +29,25 @@ export default function ActionsMenu({
}: IActionsMenuProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClose = () => setAnchorEl(null);
const setTableSettingsDialogId = useSetAtom(
tableSettingsDialogIdAtom,
globalScope
);
// On open, set tableSettingsDialogIdAtom so the derived
// tableSettingsDialogSchemaAtom can fetch the schema doc
const handleOpen: React.MouseEventHandler<HTMLButtonElement> = (e) => {
setAnchorEl(e.currentTarget);
const tableId = useFormMethods.getValues("id") as string;
setTableSettingsDialogId(tableId);
};
// Reset the tableSettingsDialogIdAtom so we fetch fresh data every time
// the menu is opened
const handleClose = () => {
setAnchorEl(null);
setTableSettingsDialogId("");
};
return (
<>
@@ -34,7 +57,7 @@ export default function ActionsMenu({
aria-controls="table-settings-actions-menu"
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
onClick={handleOpen}
>
{mode === "create" ? <ImportIcon /> : <ExportIcon />}
</IconButton>
@@ -49,12 +72,21 @@ export default function ActionsMenu({
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<ImportSettings
closeMenu={handleClose}
control={control}
useFormMethods={useFormMethods}
/>
<ExportSettings closeMenu={handleClose} control={control} />
<Suspense
fallback={
<>
<MenuItem disabled>Loading table settings</MenuItem>
<MenuItem disabled />
</>
}
>
<ImportSettings
closeMenu={handleClose}
control={control}
useFormMethods={useFormMethods}
/>
<ExportSettings closeMenu={handleClose} control={control} />
</Suspense>
</Menu>
</>
);

View File

@@ -1,15 +1,21 @@
import { useState } from "react";
import { useAtom } from "jotai";
import { Control, useWatch } from "react-hook-form";
import stringify from "json-stable-stringify-without-jsonify";
import { isEmpty } from "lodash-es";
import { merge } from "lodash-es";
import { useSnackbar } from "notistack";
import { MenuItem, DialogContentText, LinearProgress } from "@mui/material";
import { MenuItem, DialogContentText } from "@mui/material";
import { analytics, logEvent } from "@src/analytics";
import Modal from "@src/components/Modal";
import CodeEditor from "@src/components/CodeEditor";
import {
globalScope,
tableSettingsDialogSchemaAtom,
} from "@src/atoms/globalScope";
import { analytics, logEvent } from "@src/analytics";
export interface IExportSettingsProps {
closeMenu: () => void;
control: Control;
@@ -22,17 +28,12 @@ export default function ExportSettings({
const [open, setOpen] = useState(false);
const { _suggestedRules, ...values } = useWatch({ control });
// TODO:
const tableConfigState = {} as any;
// const [tableConfigState] = useTableConfig(values.id);
const { id, ref, ..._schema } = tableConfigState.doc ?? {};
const [tableSchema] = useAtom(tableSettingsDialogSchemaAtom, globalScope);
const formattedJson = stringify(
// Allow values._schema to take priority if user imported _schema before
"_schema" in values || isEmpty(_schema) ? values : { ...values, _schema },
{ ...values, _schema: merge(tableSchema, values._schema) },
{
space: 2,
// TODO: types
cmp: (a: any, b: any) =>
// Sort _schema at the end
a.key.startsWith("_")
@@ -66,16 +67,9 @@ export default function ExportSettings({
onClose={handleClose}
title="Export table settings"
header={
<>
{tableConfigState.loading && values.id && (
<LinearProgress
style={{ position: "absolute", top: 0, left: 0, right: 0 }}
/>
)}
<DialogContentText style={{ margin: "0 var(--dialog-spacing)" }}>
Export table settings and columns in JSON format
</DialogContentText>
</>
<DialogContentText style={{ margin: "0 var(--dialog-spacing)" }}>
Export table settings and columns in JSON format
</DialogContentText>
}
body={
<div style={{ marginTop: "var(--dialog-contents-spacing)" }}>

View File

@@ -1,9 +1,9 @@
import { useState } from "react";
import { useSetAtom } from "jotai";
import { useAtom, useSetAtom } from "jotai";
import { Control, useWatch } from "react-hook-form";
import type { UseFormReturn, FieldValues } from "react-hook-form";
import stringify from "json-stable-stringify-without-jsonify";
import { isEmpty, get } from "lodash-es";
import { merge, get } from "lodash-es";
import { useSnackbar } from "notistack";
import { MenuItem, DialogContentText, FormHelperText } from "@mui/material";
@@ -11,8 +11,12 @@ import { MenuItem, DialogContentText, FormHelperText } from "@mui/material";
import Modal from "@src/components/Modal";
import DiffEditor from "@src/components/CodeEditor/DiffEditor";
// import useTableConfig from "@src/hooks/useTable/useTableConfig";
import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope";
import {
globalScope,
confirmDialogAtom,
tableSettingsDialogSchemaAtom,
tableSettingsDialogAtom,
} from "@src/atoms/globalScope";
import { analytics, logEvent } from "@src/analytics";
export interface IImportSettingsProps {
@@ -32,17 +36,12 @@ export default function ImportSettings({
const [valid, setValid] = useState(true);
const { _suggestedRules, ...values } = useWatch({ control });
// TODO:
const tableConfigState = {} as any;
// const [tableConfigState] = useTableConfig(values.id);
const { id, ref, ..._schema } = tableConfigState.doc ?? {};
const [tableSchema] = useAtom(tableSettingsDialogSchemaAtom, globalScope);
const formattedJson = stringify(
// Allow values._schema to take priority if user imported _schema before
"_schema" in values || isEmpty(_schema) ? values : { ...values, _schema },
{ ...values, _schema: merge(tableSchema, values._schema) },
{
space: 2,
// TODO: types
cmp: (a: any, b: any) =>
// Sort _schema at the end
a.key.startsWith("_")
@@ -62,6 +61,7 @@ export default function ImportSettings({
const confirm = useSetAtom(confirmDialogAtom, globalScope);
const { enqueueSnackbar } = useSnackbar();
const { setValue } = useFormMethods;
const [tableSettingsDialog] = useAtom(tableSettingsDialogAtom, globalScope);
const handleImport = () => {
logEvent(analytics, "import_tableSettings");
@@ -73,7 +73,11 @@ export default function ImportSettings({
});
}
enqueueSnackbar("Imported settings");
enqueueSnackbar(
`Imported settings. Click ${
tableSettingsDialog.mode === "create" ? "Create" : "Update"
} to save to the table.`
);
handleClose();
};

View File

@@ -1,12 +1,17 @@
import { useState } from "react";
import { useSetAtom } from "jotai";
import { useAtom, useSetAtom } from "jotai";
import { useNavigate } from "react-router-dom";
import { useSnackbar } from "notistack";
import { IconButton, Menu, MenuItem, DialogContentText } from "@mui/material";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope";
import {
globalScope,
confirmDialogAtom,
updateTableAtom,
deleteTableAtom,
} from "@src/atoms/globalScope";
import { TableSettings } from "@src/types/table";
import { ROUTES } from "@src/constants/routes";
import { analytics, logEvent } from "@src/analytics";
@@ -25,40 +30,27 @@ export default function DeleteMenu({ clearDialog, data }: IDeleteMenuProps) {
const confirm = useSetAtom(confirmDialogAtom, globalScope);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const [updateTable] = useAtom(updateTableAtom, globalScope);
const handleResetStructure = async () => {
const snack = enqueueSnackbar("Resetting columns…", { persist: true });
// TODO:
// const schemaDocRef = db.doc(`${TABLE_SCHEMAS}/${data!.id}`);
// await schemaDocRef.update({ columns: {} });
await updateTable!(
{ id: data!.id, tableType: data!.tableType },
{ _schema: { columns: {} } }
);
clearDialog();
closeSnackbar(snack);
enqueueSnackbar("Columns reset");
};
const [deleteTable] = useAtom(deleteTableAtom, globalScope);
const handleDelete = async () => {
const snack = enqueueSnackbar("Deleting table…", { persist: true });
// TODO:
// const tablesDocRef = db.doc(SETTINGS);
// const tableData = (await tablesDocRef.get()).data();
// const updatedTables = tableData?.tables.filter(
// (table) => table.id !== data?.id || table.tableType !== data?.tableType
// );
// tablesDocRef.update({ tables: updatedTables });
// await db
// .collection(
// data?.tableType === "primaryCollection"
// ? TABLE_SCHEMAS
// : TABLE_GROUP_SCHEMAS
// )
// .doc(data?.id)
// .delete();
await deleteTable!(data!.id);
logEvent(analytics, "delete_table");
clearDialog();
closeSnackbar(snack);
navigate(ROUTES.home);
enqueueSnackbar("Deleted table");
};
return (
@@ -106,6 +98,7 @@ export default function DeleteMenu({ clearDialog, data }: IDeleteMenuProps) {
handleConfirm: handleResetStructure,
})
}
disabled={!updateTable}
>
Reset columns
</MenuItem>
@@ -131,6 +124,7 @@ export default function DeleteMenu({ clearDialog, data }: IDeleteMenuProps) {
handleConfirm: handleDelete,
})
}
disabled={!deleteTable}
>
Delete table
</MenuItem>

View File

@@ -4,6 +4,7 @@ import { useSnackbar } from "notistack";
import { useLocation, useNavigate } from "react-router-dom";
import { find, sortBy, get, isEmpty } from "lodash-es";
import { FieldValues } from "react-hook-form";
import { Controller } from "react-hook-form";
import { DialogContentText, Stack, Typography } from "@mui/material";
@@ -23,6 +24,9 @@ import {
rolesAtom,
rowyRunAtom,
confirmDialogAtom,
createTableAtom,
updateTableAtom,
AdditionalTableSettings,
} from "@src/atoms/globalScope";
import { TableSettings } from "@src/types/table";
import { analytics, logEvent } from "@src/analytics";
@@ -35,7 +39,6 @@ import {
TABLE_GROUP_SCHEMAS,
TABLE_SCHEMAS,
} from "@src/config/dbPaths";
import { Controller } from "react-hook-form";
import { ROUTES } from "@src/constants/routes";
const customComponents = {
@@ -88,16 +91,21 @@ export default function TableSettingsDialog() {
}
);
const [createTable] = useAtom(createTableAtom, globalScope);
const [updateTable] = useAtom(updateTableAtom, globalScope);
if (!open) return null;
// TODO: types
const handleSubmit = async (v: FieldValues) => {
const { _suggestedRules, ...values } = v;
const handleSubmit = async (v: TableSettings & AdditionalTableSettings) => {
const {
_schemaSource,
_initialColumns,
_schema,
_suggestedRules,
...values
} = v;
const data = { ...values };
if (values.schemaSource)
data.schemaSource = find(tables, { id: values.schemaSource });
const hasExtensions = !isEmpty(get(data, "_schema.extensionObjects"));
const hasWebhooks = !isEmpty(get(data, "_schema.webhooks"));
const deployExtensionsWebhooks = (onComplete?: () => void) => {
@@ -178,12 +186,10 @@ export default function TableSettingsDialog() {
);
}
// TODO:
// await settingsActions?.updateTable({
// id: data.id,
// tableType: data.tableType,
// _schema,
// });
await updateTable!(
{ id: data.id, tableType: data.tableType },
{ _schema }
);
if (onComplete) onComplete();
},
});
@@ -193,8 +199,7 @@ export default function TableSettingsDialog() {
};
if (mode === "update") {
// TODO:
// await settingsActions?.updateTable(data);
await updateTable!(data, { _schema });
deployExtensionsWebhooks();
clearDialog();
logEvent(analytics, "update_table", { type: values.tableType });
@@ -203,15 +208,16 @@ export default function TableSettingsDialog() {
const creatingSnackbar = enqueueSnackbar("Creating table…", {
persist: true,
});
// TODO:
// await settingsActions?.createTable(data);
await logEvent(analytics, "create_table", { type: values.tableType });
await createTable!(data, {
_schemaSource,
_initialColumns,
_schema,
_suggestedRules,
});
logEvent(analytics, "create_table", { type: values.tableType });
deployExtensionsWebhooks(() => {
if (location.pathname === ROUTES.tables) {
navigate(`${ROUTES.table}/${values.id}`);
} else {
navigate(values.id);
}
navigate(`${ROUTES.table}/${values.id}`);
clearDialog();
closeSnackbar(creatingSnackbar);
});
@@ -237,10 +243,7 @@ export default function TableSettingsDialog() {
return (
<FormDialog
onClose={clearDialog}
title={
(mode === "create" ? "Create table" : "Table settings") +
" (INCOMPLETE)"
}
title={mode === "create" ? "Create table" : "Table settings"}
fields={fields}
customBody={(formFieldsProps) => {
const { errors } = formFieldsProps.useFormMethods.formState;
@@ -444,11 +447,12 @@ export default function TableSettingsDialog() {
}}
customComponents={customComponents}
values={{ ...data }}
onSubmit={handleSubmit}
onSubmit={handleSubmit as any}
SubmitButtonProps={{
children: mode === "create" ? "Create" : "Update",
// TODO:
disabled: true,
disabled:
(mode === "create" && !createTable) ||
(mode === "update" && !updateTable),
}}
/>
);

View File

@@ -428,7 +428,7 @@ export const tableSettings = (
? {
step: "columns",
type: FieldType.singleSelect,
name: "schemaSource",
name: "_schemaSource",
label: "Copy columns from existing table (optional)",
labelPlural: "tables",
options: tables,

View File

@@ -0,0 +1,133 @@
import { find, get } from "lodash-es";
import { FieldType } from "@src/constants/fields";
import { IFieldConfig } from "./types";
// // Import field configs
// import ShortText from "./ShortText";
// import LongText from "./LongText";
// import RichText from "./RichText";
// import Email from "./Email";
// import Phone from "./Phone";
// import Url from "./Url";
// import Number_ from "./Number";
// import Checkbox from "./Checkbox";
// import Percentage from "./Percentage";
// import Rating from "./Rating";
// import Slider from "./Slider";
// import Color from "./Color";
// import Date_ from "./Date";
// import DateTime from "./DateTime";
// import Duration from "./Duration";
// import Image_ from "./Image";
// import File_ from "./File";
// import SingleSelect from "./SingleSelect";
// import MultiSelect from "./MultiSelect";
// import SubTable from "./SubTable";
// import ConnectTable from "./ConnectTable";
// import ConnectService from "./ConnectService";
// import Json from "./Json";
// import Code from "./Code";
// import Action from "./Action";
// import Derivative from "./Derivative";
// // import Aggregate from "./Aggregate";
// import CreatedBy from "./CreatedBy";
// import UpdatedBy from "./UpdatedBy";
// import CreatedAt from "./CreatedAt";
// import UpdatedAt from "./UpdatedAt";
// import User from "./User";
// import Id from "./Id";
// import Status from "./Status";
// import Connector from "./Connector";
// import { TableColumn } from "../Table";
// Export field configs in order for FieldsDropdown
export const FIELDS: IFieldConfig[] = [
// // TEXT
// ShortText,
// LongText,
// RichText,
// Email,
// Phone,
// Url,
// // SELECT
// SingleSelect,
// MultiSelect,
// // NUMERIC
// Number_,
// Checkbox,
// Percentage,
// Rating,
// Slider,
// Color,
// // DATE & TIME
// Date_,
// DateTime,
// Duration,
// // FILE
// Image_,
// File_,
// // CONNECTION
// Connector,
// SubTable,
// ConnectTable,
// ConnectService,
// // CODE
// Json,
// Code,
// // CLOUD FUNCTION
// Action,
// Derivative,
// // Aggregate,
// Status,
// // AUDITING
// CreatedBy,
// UpdatedBy,
// CreatedAt,
// UpdatedAt,
// // METADATA
// User,
// Id,
];
/**
* Returns specific property of field config
* @param fieldType
*/
export const getFieldProp = (
prop: keyof IFieldConfig,
fieldType: FieldType
) => {
const field = find(FIELDS, { type: fieldType });
return get(field, prop);
};
/**
* Returns `true` if it receives an existing fieldType
* @param fieldType
*/
export const isFieldType = (fieldType: any) => {
const fieldTypes = FIELDS.map((field) => field.type);
return fieldTypes.includes(fieldType);
};
/**
* Returns array of fieldTypes with dataType included dataTypes array
* @param dataTypes
*/
export const hasDataTypes = (dataTypes: string[]) => {
const fieldTypes = FIELDS.map((field) => field.type);
return fieldTypes.filter((fieldType) =>
dataTypes.includes(getFieldProp("dataType", fieldType))
);
};
export const getColumnType = (column: {
type: FieldType;
config: {
renderFieldType: FieldType;
};
}) =>
column.type === FieldType.derivative
? column.config.renderFieldType
: column.type;

View File

@@ -0,0 +1,86 @@
import { FieldType } from "@src/constants/fields";
import { FormatterProps, EditorProps } from "react-data-grid";
import { Control, UseFormReturn } from "react-hook-form";
import { PopoverProps } from "@mui/material";
import { DocumentReference, WhereFilterOp } from "firebase/firestore";
// import { SelectedCell } from "@src/atoms/ContextMenu";
// import { IContextMenuActions } from "./_BasicCell/BasicCellContextMenuActions";
export { FieldType };
export interface IFieldConfig {
type: FieldType;
name: string;
group: string;
dataType: string;
initializable?: boolean;
requireConfiguration?: boolean;
initialValue: any;
icon?: React.ReactNode;
description?: string;
setupGuideLink?: string;
// contextMenuActions?: (
// selectedCell: SelectedCell,
// reset: () => Promise<void>
// ) => IContextMenuActions[];
TableCell: React.ComponentType<FormatterProps<any>>;
TableEditor: React.ComponentType<EditorProps<any, any>>;
SideDrawerField: React.ComponentType<ISideDrawerFieldProps>;
settings?: React.ComponentType<ISettingsProps>;
settingsValidator?: (config: Record<string, any>) => Record<string, string>;
filter?: {
operators: IFilterOperator[];
customInput?: React.ComponentType<IFiltersProps>;
defaultValue?: any;
valueFormatter?: (value: any) => string;
};
sortKey?: string;
csvExportFormatter?: (value: any, config?: any) => string;
csvImportParser?: (value: string, config?: any) => any;
}
export interface IBasicCellProps {
value: any;
type: FieldType;
name: string;
}
export interface IHeavyCellProps extends IBasicCellProps, FormatterProps<any> {
column: FormatterProps<any>["column"] & { config?: Record<string, any> };
onSubmit: (value: any) => void;
docRef: DocumentReference;
disabled: boolean;
}
export interface IPopoverInlineCellProps extends IHeavyCellProps {
showPopoverCell: React.Dispatch<React.SetStateAction<boolean>>;
}
export interface IPopoverCellProps extends IPopoverInlineCellProps {
parentRef: PopoverProps["anchorEl"];
}
export interface ISideDrawerFieldProps {
column: FormatterProps<any>["column"] & { config?: Record<string, any> };
control: Control;
docRef: DocumentReference;
disabled: boolean;
useFormMethods: UseFormReturn;
}
export interface ISettingsProps {
onChange: (key: string) => (value: any) => void;
config: Record<string, any>;
fieldName: string;
onBlur: React.FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
errors: Record<string, any>;
}
// TODO: WRITE TYPES
export interface IFiltersProps {
onChange: (key: string) => (value: any) => void;
[key: string]: any;
}
export interface IFilterOperator {
value: WhereFilterOp;
label: string;
}

View File

@@ -1,168 +0,0 @@
import { memo, useEffect, useCallback } from "react";
import { atom, useAtom, useSetAtom } from "jotai";
import { useAtomCallback } from "jotai/utils";
import { FirebaseOptions, initializeApp } from "firebase/app";
import { getAuth, connectAuthEmulator, getIdTokenResult } from "firebase/auth";
import {
initializeFirestore,
connectFirestoreEmulator,
enableMultiTabIndexedDbPersistence,
} from "firebase/firestore";
import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom";
import {
globalScope,
projectIdAtom,
projectSettingsAtom,
updateProjectSettingsAtom,
publicSettingsAtom,
updatePublicSettingsAtom,
currentUserAtom,
userRolesAtom,
userSettingsAtom,
updateUserSettingsAtom,
} from "@src/atoms/globalScope";
import { SETTINGS, PUBLIC_SETTINGS, USERS } from "@src/config/dbPaths";
export const envConfig = {
apiKey: process.env.REACT_APP_FIREBASE_PROJECT_WEB_API_KEY,
authDomain: `${process.env.REACT_APP_FIREBASE_PROJECT_ID}.firebaseapp.com`,
databaseURL: `https://${process.env.REACT_APP_FIREBASE_PROJECT_ID}.firebaseio.com`,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
storageBucket: `${process.env.REACT_APP_FIREBASE_PROJECT_ID}.appspot.com`,
};
// Connect emulators based on env vars
const envConnectEmulators =
process.env.NODE_ENV === "test" ||
process.env.REACT_APP_FIREBASE_EMULATORS === "true";
/**
* Store Firebase config here so it can be set programmatically.
* This lets us switch between Firebase projects.
* Root atom from which app, auth, db, storage are derived.
*/
export const firebaseConfigAtom = atom<FirebaseOptions>(envConfig);
/** Store Firebase app instance */
export const firebaseAppAtom = atom((get) => {
const firebaseConfig = get(firebaseConfigAtom);
return initializeApp(firebaseConfig, firebaseConfig.projectId);
});
/**
* Store Firebase Auth instance for current app.
* Connects to emulators based on env vars.
*/
export const firebaseAuthAtom = atom((get) => {
const auth = getAuth(get(firebaseAppAtom));
if (envConnectEmulators && !(window as any).firebaseAuthEmulatorStarted) {
connectAuthEmulator(auth, "http://localhost:9099", {
disableWarnings: true,
});
(window as any).firebaseAuthEmulatorStarted = true;
}
return auth;
});
/**
* Store Firestore instance for current app.
* Connects to emulators based on env vars, or enables multi-tab indexed db persistence.
*/
export const firebaseDbAtom = atom((get) => {
const db = initializeFirestore(get(firebaseAppAtom), {
ignoreUndefinedProperties: true,
});
if (!(window as any).firebaseDbStarted) {
if (envConnectEmulators) connectFirestoreEmulator(db, "localhost", 9299);
else enableMultiTabIndexedDbPersistence(db);
(window as any).firebaseDbStarted = true;
}
return db;
});
/**
* When rendered, connects to a Firebase project.
*
* Sets project ID, project settings, public settings, current user, user roles, and user settings.
*/
export const ProjectSourceFirebase = memo(function ProjectSourceFirebase() {
// Set projectId from Firebase project
const [firebaseConfig] = useAtom(firebaseConfigAtom, globalScope);
const setProjectId = useSetAtom(projectIdAtom, globalScope);
useEffect(() => {
setProjectId(firebaseConfig.projectId || "");
}, [firebaseConfig.projectId, setProjectId]);
// Get current user and store in atoms
const [firebaseAuth] = useAtom(firebaseAuthAtom, globalScope);
const [currentUser, setCurrentUser] = useAtom(currentUserAtom, globalScope);
const setUserRoles = useSetAtom(userRolesAtom, globalScope);
// Must use `useAtomCallback`, otherwise `useAtom(updateUserSettingsAtom)`
// will cause infinite re-render
const updateUserSettings = useAtomCallback(
useCallback((get) => get(updateUserSettingsAtom), []),
globalScope
);
useEffect(() => {
// Suspend when currentUser has not been read yet
(setCurrentUser as any)(new Promise(() => {}));
const unsubscribe = firebaseAuth.onAuthStateChanged(async (user) => {
setCurrentUser(user);
if (user) {
// Get user roles
const tokenResult = await getIdTokenResult(user);
const roles = (tokenResult.claims.roles as string[]) ?? [];
setUserRoles(roles);
// Update user settings doc with roles for User Management page
const _updateUserSettings = await updateUserSettings();
if (_updateUserSettings) _updateUserSettings({ roles });
} else {
setUserRoles([]);
}
});
return () => {
unsubscribe();
};
}, [firebaseAuth, setCurrentUser, setUserRoles, updateUserSettings]);
// Store public settings in atom
useFirestoreDocWithAtom(publicSettingsAtom, globalScope, PUBLIC_SETTINGS, {
updateDataAtom: updatePublicSettingsAtom,
});
// Store project settings in atom when a user is signed in.
// If they have no access, display AccessDenied screen via ErrorBoundary.
useFirestoreDocWithAtom(
projectSettingsAtom,
globalScope,
currentUser ? SETTINGS : undefined,
{ updateDataAtom: updateProjectSettingsAtom }
);
// Store user settings in atom when a user is signed in
useFirestoreDocWithAtom(userSettingsAtom, globalScope, USERS, {
pathSegments: [currentUser?.uid],
createIfNonExistent: currentUser
? {
user: {
email: currentUser.email || "",
displayName: currentUser.displayName || undefined,
photoURL: currentUser.photoURL || undefined,
phoneNumber: currentUser.phoneNumber || undefined,
},
}
: undefined,
updateDataAtom: updateUserSettingsAtom,
});
return null;
});
export default ProjectSourceFirebase;

View File

@@ -0,0 +1,35 @@
import { memo, useEffect } from "react";
import { useAtom, useSetAtom } from "jotai";
import { globalScope, projectIdAtom } from "@src/atoms/globalScope";
import { firebaseConfigAtom } from "./init";
import { useAuthUser } from "./useAuthUser";
import { useSettingsDocs } from "./useSettingsDocs";
import { useTableFunctions } from "./useTableFunctions";
/**
* When rendered, connects to a Firebase project and populates
* all atoms in src/atoms/globalScope/project.
*/
export const ProjectSourceFirebase = memo(function ProjectSourceFirebase() {
// Set projectId from Firebase project
const [firebaseConfig] = useAtom(firebaseConfigAtom, globalScope);
const setProjectId = useSetAtom(projectIdAtom, globalScope);
useEffect(() => {
setProjectId(firebaseConfig.projectId || "");
}, [firebaseConfig.projectId, setProjectId]);
// Sets currentUser and userRoles based on Firebase Auth user.
useAuthUser();
// Sets listeners to public settings, project settings, and user settings.
// Also sets functions to update those documents.
useSettingsDocs();
useTableFunctions();
console.log("rerender");
return null;
});
export default ProjectSourceFirebase;

View File

@@ -0,0 +1,4 @@
export * from "./ProjectSourceFirebase";
export { default } from "./ProjectSourceFirebase";
export * from "./init";

View File

@@ -0,0 +1,65 @@
import { atom } from "jotai";
import { FirebaseOptions, initializeApp } from "firebase/app";
import { getAuth, connectAuthEmulator } from "firebase/auth";
import {
initializeFirestore,
connectFirestoreEmulator,
enableMultiTabIndexedDbPersistence,
} from "firebase/firestore";
export const envConfig = {
apiKey: process.env.REACT_APP_FIREBASE_PROJECT_WEB_API_KEY,
authDomain: `${process.env.REACT_APP_FIREBASE_PROJECT_ID}.firebaseapp.com`,
databaseURL: `https://${process.env.REACT_APP_FIREBASE_PROJECT_ID}.firebaseio.com`,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
storageBucket: `${process.env.REACT_APP_FIREBASE_PROJECT_ID}.appspot.com`,
};
// Connect emulators based on env vars
const envConnectEmulators =
process.env.NODE_ENV === "test" ||
process.env.REACT_APP_FIREBASE_EMULATORS === "true";
/**
* Store Firebase config here so it can be set programmatically.
* This lets us switch between Firebase projects.
* Root atom from which app, auth, db, storage are derived.
*/
export const firebaseConfigAtom = atom<FirebaseOptions>(envConfig);
/** Store Firebase app instance */
export const firebaseAppAtom = atom((get) => {
const firebaseConfig = get(firebaseConfigAtom);
return initializeApp(firebaseConfig, firebaseConfig.projectId);
});
/**
* Store Firebase Auth instance for current app.
* Connects to emulators based on env vars.
*/
export const firebaseAuthAtom = atom((get) => {
const auth = getAuth(get(firebaseAppAtom));
if (envConnectEmulators && !(window as any).firebaseAuthEmulatorStarted) {
connectAuthEmulator(auth, "http://localhost:9099", {
disableWarnings: true,
});
(window as any).firebaseAuthEmulatorStarted = true;
}
return auth;
});
/**
* Store Firestore instance for current app.
* Connects to emulators based on env vars, or enables multi-tab indexed db persistence.
*/
export const firebaseDbAtom = atom((get) => {
const db = initializeFirestore(get(firebaseAppAtom), {
ignoreUndefinedProperties: true,
});
if (!(window as any).firebaseDbStarted) {
if (envConnectEmulators) connectFirestoreEmulator(db, "localhost", 9299);
else enableMultiTabIndexedDbPersistence(db);
(window as any).firebaseDbStarted = true;
}
return db;
});

View File

@@ -0,0 +1,54 @@
import { useEffect, useCallback } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useAtomCallback } from "jotai/utils";
import { getIdTokenResult } from "firebase/auth";
import {
globalScope,
currentUserAtom,
userRolesAtom,
updateUserSettingsAtom,
} from "@src/atoms/globalScope";
import { firebaseAuthAtom } from "./init";
/**
* Sets currentUser and userRoles based on Firebase Auth user
*/
export function useAuthUser() {
// Get current user and store in atoms
const [firebaseAuth] = useAtom(firebaseAuthAtom, globalScope);
const setCurrentUser = useSetAtom(currentUserAtom, globalScope);
const setUserRoles = useSetAtom(userRolesAtom, globalScope);
// Must use `useAtomCallback`, otherwise `useAtom(updateUserSettingsAtom)`
// will cause infinite re-render
const updateUserSettings = useAtomCallback(
useCallback((get) => get(updateUserSettingsAtom), []),
globalScope
);
useEffect(() => {
// Suspend when currentUser has not been read yet
(setCurrentUser as any)(new Promise(() => {}));
const unsubscribe = firebaseAuth.onAuthStateChanged(async (user) => {
setCurrentUser(user);
if (user) {
// Get user roles
const tokenResult = await getIdTokenResult(user);
const roles = (tokenResult.claims.roles as string[]) ?? [];
setUserRoles(roles);
// Update user settings doc with roles for User Management page
const _updateUserSettings = await updateUserSettings();
if (_updateUserSettings) _updateUserSettings({ roles });
} else {
setUserRoles([]);
}
});
return () => {
unsubscribe();
};
}, [firebaseAuth, setCurrentUser, setUserRoles, updateUserSettings]);
}

View File

@@ -0,0 +1,53 @@
import { useAtom } from "jotai";
import {
globalScope,
projectSettingsAtom,
updateProjectSettingsAtom,
publicSettingsAtom,
updatePublicSettingsAtom,
currentUserAtom,
userSettingsAtom,
updateUserSettingsAtom,
} from "@src/atoms/globalScope";
import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom";
import { SETTINGS, PUBLIC_SETTINGS, USERS } from "@src/config/dbPaths";
/**
* Sets listeners to public settings, project settings, and user settings.
* Also sets functions to update those documents.
*/
export function useSettingsDocs() {
const [currentUser] = useAtom(currentUserAtom, globalScope);
// Store public settings in atom
useFirestoreDocWithAtom(publicSettingsAtom, globalScope, PUBLIC_SETTINGS, {
updateDataAtom: updatePublicSettingsAtom,
});
// Store project settings in atom when a user is signed in.
// If they have no access, display AccessDenied screen via ErrorBoundary.
useFirestoreDocWithAtom(
projectSettingsAtom,
globalScope,
currentUser ? SETTINGS : undefined,
{ updateDataAtom: updateProjectSettingsAtom }
);
// Store user settings in atom when a user is signed in
useFirestoreDocWithAtom(userSettingsAtom, globalScope, USERS, {
pathSegments: [currentUser?.uid],
createIfNonExistent: currentUser
? {
user: {
email: currentUser.email || "",
displayName: currentUser.displayName || undefined,
photoURL: currentUser.photoURL || undefined,
phoneNumber: currentUser.phoneNumber || undefined,
},
}
: undefined,
updateDataAtom: updateUserSettingsAtom,
});
}

View File

@@ -0,0 +1,223 @@
import { useEffect, useCallback } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useAtomCallback } from "jotai/utils";
import { doc, getDoc, setDoc, deleteDoc } from "firebase/firestore";
import { camelCase, find, findIndex, isEmpty } from "lodash-es";
import {
globalScope,
projectSettingsAtom,
createTableAtom,
updateTableAtom,
deleteTableAtom,
getTableSchemaAtom,
AdditionalTableSettings,
MinimumTableSettings,
} from "@src/atoms/globalScope";
import { firebaseDbAtom } from "./init";
import {
SETTINGS,
TABLE_SCHEMAS,
TABLE_GROUP_SCHEMAS,
} from "@src/config/dbPaths";
import { TableSettings, TableSchema } from "@src/types/table";
import { FieldType } from "@src/constants/fields";
import { getFieldProp } from "@src/components/fields";
export function useTableFunctions() {
const [firebaseDb] = useAtom(firebaseDbAtom, globalScope);
// Create a function to get the latest tables from project settings,
// so we dont create new functions when tables change
const getTables = useAtomCallback(
useCallback((get) => get(projectSettingsAtom).tables, []),
globalScope
);
// Set the createTable function
const setCreateTable = useSetAtom(createTableAtom, globalScope);
useEffect(() => {
console.log("effect firebaseDb");
setCreateTable(
() =>
async (
settings: TableSettings,
additionalSettings?: AdditionalTableSettings
) => {
const {
_schemaSource,
_initialColumns = {},
_schema,
} = additionalSettings || {};
// Get latest tables
const tables = (await getTables()) || [];
console.log("createTable", tables);
// Get columns from imported table settings or _schemaSource if provided
let columns: NonNullable<TableSchema["columns"]> =
Array.isArray(_schema?.columns) || !_schema?.columns
? {}
: _schema?.columns;
// If _schemaSource is provided, get the schema doc for that table
if (_schemaSource) {
const sourceTable = find(tables, ["id", _schemaSource]);
if (sourceTable) {
const sourceDocRef = doc(
firebaseDb,
sourceTable.tableType !== "collectionGroup"
? TABLE_SCHEMAS
: TABLE_GROUP_SCHEMAS,
_schemaSource
);
const sourceDoc = await getDoc(sourceDocRef);
columns = sourceDoc.get("columns") || {};
}
}
// Add columns from `_initialColumns`
for (const [type, checked] of Object.entries(_initialColumns)) {
if (
checked &&
// Make sure we dont have
!Object.values(columns).some((column) => column.type === type)
)
columns["_" + camelCase(type)] = {
type,
name: getFieldProp("name", type as FieldType),
key: "_" + camelCase(type),
fieldName: "_" + camelCase(type),
config: {},
index: Object.values(columns).length,
};
}
// Appends table to settings doc
const promiseUpdateSettings = setDoc(
doc(firebaseDb, SETTINGS),
{ tables: [...tables, settings] },
{ merge: true }
);
// Creates schema doc with columns
const { functionConfigPath, functionBuilderRef, ...schemaToWrite } =
_schema ?? {};
const tableSchemaDocRef = doc(
firebaseDb,
settings.tableType !== "collectionGroup"
? TABLE_SCHEMAS
: TABLE_GROUP_SCHEMAS,
settings.id
);
const promiseAddSchema = await setDoc(
tableSchemaDocRef,
{ ...schemaToWrite, columns },
{ merge: true }
);
// Wait for both to complete
await Promise.all([promiseUpdateSettings, promiseAddSchema]);
}
);
}, [firebaseDb, getTables, setCreateTable]);
// Set the createTable function
const setUpdateTable = useSetAtom(updateTableAtom, globalScope);
useEffect(() => {
setUpdateTable(
() =>
async (
settings: MinimumTableSettings,
additionalSettings?: AdditionalTableSettings
) => {
const { _schema } = additionalSettings || {};
// Get latest tables
const tables = [...((await getTables()) || [])];
const foundIndex = findIndex(tables, ["id", settings.id]);
const tableIndex = foundIndex > -1 ? foundIndex : tables.length;
// Shallow merge new settings with old
tables[tableIndex] = { ...tables[tableIndex], ...settings };
// Updates settings doc with new tables array
const promiseUpdateSettings = setDoc(
doc(firebaseDb, SETTINGS),
{ tables },
{ merge: true }
);
// Updates schema doc if param is provided
const { functionConfigPath, functionBuilderRef, ...schemaToWrite } =
_schema ?? {};
const tableSchemaDocRef = doc(
firebaseDb,
settings.tableType !== "collectionGroup"
? TABLE_SCHEMAS
: TABLE_GROUP_SCHEMAS,
settings.id
);
const promiseUpdateSchema = isEmpty(schemaToWrite)
? Promise.resolve()
: await setDoc(tableSchemaDocRef, schemaToWrite, { merge: true });
// Wait for both to complete
await Promise.all([promiseUpdateSettings, promiseUpdateSchema]);
}
);
}, [firebaseDb, getTables, setUpdateTable]);
// Set the deleteTable function
const setDeleteTable = useSetAtom(deleteTableAtom, globalScope);
useEffect(() => {
setDeleteTable(() => async (id: string) => {
// Get latest tables
const tables = (await getTables()) || [];
const table = find(tables, ["id", id]);
// Removes table from settings doc array
const promiseUpdateSettings = setDoc(
doc(firebaseDb, SETTINGS),
{ tables: tables.filter((table) => table.id !== id) },
{ merge: true }
);
// Deletes table schema doc
const tableSchemaDocRef = doc(
firebaseDb,
table?.tableType === "collectionGroup"
? TABLE_GROUP_SCHEMAS
: TABLE_SCHEMAS,
id
);
const promiseDeleteSchema = deleteDoc(tableSchemaDocRef);
// Wait for both to complete
await Promise.all([promiseUpdateSettings, promiseDeleteSchema]);
});
}, [firebaseDb, getTables, setDeleteTable]);
// Set the getTableSchema function
const setGetTableSchema = useSetAtom(getTableSchemaAtom, globalScope);
useEffect(() => {
setGetTableSchema(() => async (id: string) => {
// Get latest tables
const tables = (await getTables()) || [];
const table = find(tables, ["id", id]);
const tableSchemaDocRef = doc(
firebaseDb,
table?.tableType === "collectionGroup"
? TABLE_GROUP_SCHEMAS
: TABLE_SCHEMAS,
id
);
return getDoc(tableSchemaDocRef).then(
(doc) => (doc.data() || {}) as TableSchema
);
});
}, [firebaseDb, getTables, setGetTableSchema]);
}

View File

@@ -21,8 +21,8 @@ export type TableSettings = {
name: string;
roles: string[];
description: string;
section: string;
description?: string;
tableType: "primaryCollection" | "collectionGroup";
@@ -39,6 +39,7 @@ export type TableSchema = {
filters?: TableFilter[];
functionConfigPath?: string;
functionBuilderRef?: any;
extensionObjects?: any[];
webhooks?: any[];