Merge pull request #957 from rowyio/develop

Develop
This commit is contained in:
Shams
2022-11-18 03:45:21 +01:00
committed by GitHub
93 changed files with 3044 additions and 3013 deletions

View File

@@ -8,7 +8,7 @@ on:
env:
REACT_APP_FIREBASE_PROJECT_ID: rowyio
REACT_APP_FIREBASE_PROJECT_WEB_API_KEY:
"${{ secrets.FIREBASE_WEB_API_KEY_ROWYIO }}"
"${{ secrets.FIREBASE_WEB_API_KEY_TRYROWY }}"
CI: ""
jobs:
build_and_preview:
@@ -27,6 +27,6 @@ jobs:
with:
repoToken: "${{ secrets.GITHUB_TOKEN }}"
firebaseServiceAccount:
"${{ secrets.FIREBASE_SERVICE_ACCOUNT_ROWYIO }}"
"${{ secrets.FIREBASE_SERVICE_ACCOUNT_TRYROWY }}"
expires: 14d
projectId: rowyio

View File

@@ -15,10 +15,9 @@
"@mui/icons-material": "5.6.0",
"@mui/lab": "^5.0.0-alpha.76",
"@mui/material": "5.6.0",
"@mui/styles": "5.6.0",
"@mui/x-date-pickers": "^5.0.0-alpha.4",
"@rowy/form-builder": "^0.6.2",
"@rowy/multiselect": "^0.4.0",
"@rowy/form-builder": "^0.7.0",
"@rowy/multiselect": "^0.4.1",
"@tinymce/tinymce-react": "^3",
"@uiw/react-md-editor": "^3.14.1",
"algoliasearch": "^4.13.1",
@@ -29,7 +28,7 @@
"date-fns": "^2.28.0",
"dompurify": "^2.3.6",
"file-saver": "^2.0.5",
"firebase": "^9.6.11",
"firebase": "^9.12.1",
"firebaseui": "^6.0.1",
"jotai": "^1.8.4",
"json-stable-stringify-without-jsonify": "^1.0.1",
@@ -48,6 +47,7 @@
"react-beautiful-dnd": "^13.1.0",
"react-color-palette": "^6.2.0",
"react-data-grid": "7.0.0-beta.5",
"react-detect-offline": "^2.4.5",
"react-div-100vh": "^0.7.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
@@ -157,6 +157,7 @@
"@types/node": "^17.0.23",
"@types/react": "^18.0.5",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-detect-offline": "^2.4.1",
"@types/react-div-100vh": "^0.4.0",
"@types/react-dom": "^18.0.0",
"@types/react-router-dom": "^5.3.3",

View File

@@ -58,7 +58,7 @@ const ProjectSettingsPage = lazy(() => import("@src/pages/Settings/ProjectSettin
// prettier-ignore
const MembersPage = lazy(() => import("@src/pages/Settings/MembersPage" /* webpackChunkName: "MembersPage" */));
// prettier-ignore
const DebugSettingsPage = lazy(() => import("@src/pages/Settings/DebugSettingsPage" /* webpackChunkName: "DebugSettingsPage" */));
const DebugPage = lazy(() => import("@src/pages/Settings/DebugPage" /* webpackChunkName: "DebugPage" */));
export default function App() {
const [currentUser] = useAtom(currentUserAtom, projectScope);
@@ -153,14 +153,8 @@ export default function App() {
}
/>
<Route path={ROUTES.members} element={<MembersPage />} />
<Route
path={ROUTES.debugSettings}
element={
<AdminRoute>
<DebugSettingsPage />
</AdminRoute>
}
/>
<Route path={ROUTES.debug} element={<DebugPage />} />
</Route>
</Routes>
)}

View File

@@ -1,9 +1,12 @@
import { atom } from "jotai";
import { sortBy } from "lodash-es";
import { ThemeOptions } from "@mui/material";
import { userRolesAtom } from "./auth";
import { UserSettings } from "./user";
import {
PublicSettings,
ProjectSettings,
UserSettings,
} from "@src/types/settings";
import {
UpdateDocFunction,
UpdateCollectionDocFunction,
@@ -15,22 +18,6 @@ import { FunctionSettings } from "@src/types/function";
export const projectIdAtom = atom<string>("");
/** Public settings are visible to unauthenticated users */
export type PublicSettings = Partial<{
signInOptions: Array<
| "google"
| "twitter"
| "facebook"
| "github"
| "microsoft"
| "apple"
| "yahoo"
| "email"
| "phone"
| "anonymous"
>;
theme: Record<"base" | "light" | "dark", ThemeOptions>;
}>;
/** Public settings are visible to unauthenticated users */
export const publicSettingsAtom = atom<PublicSettings>({});
/** Stores a function that updates public settings */
@@ -38,21 +25,6 @@ export const updatePublicSettingsAtom = atom<
UpdateDocFunction<PublicSettings> | undefined
>(undefined);
/** Project settings are visible to authenticated users */
export type ProjectSettings = Partial<{
tables: TableSettings[];
setupCompleted: boolean;
rowyRunUrl: string;
rowyRunRegion: string;
rowyRunDeployStatus: "BUILDING" | "COMPLETE";
services: Partial<{
hooks: string;
builder: string;
terminal: string;
}>;
}>;
/** Project settings are visible to authenticated users */
export const projectSettingsAtom = atom<ProjectSettings>({});
/**
@@ -74,10 +46,10 @@ export const tablesAtom = atom<TableSettings[]>((get) => {
const tables = get(projectSettingsAtom).tables || [];
return sortBy(tables, "name")
.filter(
(table) =>
userRoles.includes("ADMIN") ||
table.roles.some((role) => userRoles.includes(role))
.filter((table) =>
userRoles.includes("ADMIN") || Array.isArray(table.roles)
? table.roles.some((role) => userRoles.includes(role))
: false
)
.map((table) => ({
...table,

View File

@@ -59,9 +59,8 @@ export type ConfirmDialogProps = {
*/
export const confirmDialogAtom = atom(
{ open: false } as ConfirmDialogProps,
(get, set, update: Partial<ConfirmDialogProps>) => {
(_, set, update: Partial<ConfirmDialogProps>) => {
set(confirmDialogAtom, {
...get(confirmDialogAtom),
open: true, // Dont require this to be set explicitly
...update,
});

View File

@@ -1,45 +1,12 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { merge } from "lodash-es";
import { ThemeOptions } from "@mui/material";
import themes from "@src/theme";
import { publicSettingsAtom } from "./project";
import {
UpdateDocFunction,
TableFilter,
TableRowRef,
TableSort,
} from "@src/types/table";
import { UserSettings } from "@src/types/settings";
import { UpdateDocFunction } from "@src/types/table";
/** User info and settings */
export type UserSettings = Partial<{
_rowy_ref: TableRowRef;
/** Synced from user auth info */
user: {
email: string;
displayName?: string;
photoURL?: string;
phoneNumber?: string;
};
roles: string[];
theme: Record<"base" | "light" | "dark", ThemeOptions>;
favoriteTables: string[];
/** Stores user overrides */
tables: Record<
string,
Partial<{
filters: TableFilter[];
hiddenFields: string[];
sorts: TableSort[];
}>
>;
/** Stores table tutorial completion */
tableTutorialComplete?: boolean;
}>;
/** User info and settings */
export const userSettingsAtom = atom<UserSettings>({});
/** Stores a function that updates user settings */

View File

@@ -1,10 +1,12 @@
import { atom } from "jotai";
import { findIndex } from "lodash-es";
import { FieldType } from "@src/constants/fields";
import {
tableColumnsOrderedAtom,
tableColumnsReducer,
updateTableSchemaAtom,
tableSchemaAtom,
} from "./table";
import { ColumnConfig } from "@src/types/table";
@@ -14,6 +16,7 @@ export interface IAddColumnOptions {
/** Index to add column at. If undefined, adds to end */
index?: number;
}
/**
* Set function adds a column to tableSchema, to the end or by index.
* Also fixes any issues with column indexes, so they go from 0 to length - 1
@@ -52,6 +55,7 @@ export interface IUpdateColumnOptions {
/** If passed, reorders the column to the index */
index?: number;
}
/**
* Set function updates a column in tableSchema
* @throws Error if column not found
@@ -75,7 +79,7 @@ export const updateColumnAtom = atom(
throw new Error(`Column with key "${key}" not found`);
// If column is not being reordered, just update the config
if (!index) {
if (index === undefined) {
tableColumnsOrdered[currentIndex] = {
...tableColumnsOrdered[currentIndex],
...config,
@@ -93,6 +97,8 @@ export const updateColumnAtom = atom(
});
}
console.log(tableColumnsOrdered);
// Reduce array into single object with updated indexes
const updatedColumns = tableColumnsOrdered.reduce(tableColumnsReducer, {});
await updateTableSchema({ columns: updatedColumns });
@@ -110,13 +116,50 @@ export const updateColumnAtom = atom(
* ```
*/
export const deleteColumnAtom = atom(null, async (get, _set, key: string) => {
const tableSchema = get(tableSchemaAtom);
const tableColumnsOrdered = [...get(tableColumnsOrderedAtom)];
const updateTableSchema = get(updateTableSchemaAtom);
if (!updateTableSchema) throw new Error("Cannot update table schema");
const updatedColumns = tableColumnsOrdered
.filter((c) => c.key !== key)
.map((c) => {
// remove column from derivatives listener fields
if (c.type === FieldType.derivative) {
return {
...c,
config: {
...c.config,
listenerFields:
c.config?.listenerFields?.filter((f) => f !== key) ?? [],
},
};
} else if (c.type === FieldType.action) {
return {
...c,
config: {
...c.config,
requiredFields:
c.config?.requiredFields?.filter((f) => f !== key) ?? [],
},
};
} else {
return c;
}
})
.reduce(tableColumnsReducer, {});
await updateTableSchema({ columns: updatedColumns }, [`columns.${key}`]);
const updatedExtensionObjects = tableSchema?.extensionObjects?.map(
(extension) => {
return {
...extension,
requiredFields: extension.requiredFields.filter((f) => f !== key),
};
}
);
await updateTableSchema(
{ columns: updatedColumns, extensionObjects: updatedExtensionObjects },
[`columns.${key}`]
);
});

View File

@@ -6,6 +6,7 @@ import {
set as _set,
isEqual,
unset,
filter,
} from "lodash-es";
import { currentUserAtom } from "@src/atoms/projectScope";
@@ -29,6 +30,7 @@ import {
updateRowData,
omitRowyFields,
} from "@src/utils/table";
import { arrayRemove, arrayUnion } from "firebase/firestore";
export interface IAddRowOptions {
/** The row or array of rows to add */
@@ -306,6 +308,10 @@ export interface IUpdateFieldOptions {
ignoreRequiredFields?: boolean;
/** Optionally, disable checking if the updated value is equal to the current value. By default, we skip the update if theyre equal. */
disableCheckEquality?: boolean;
/** Optionally, uses firestore's arrayUnion with the given value. Appends given value items to the existing array */
useArrayUnion?: boolean;
/** Optionally, uses firestore's arrayRemove with the given value. Removes given value items from the existing array */
useArrayRemove?: boolean;
}
/**
* Set function updates or deletes a field in a row.
@@ -331,6 +337,8 @@ export const updateFieldAtom = atom(
deleteField,
ignoreRequiredFields,
disableCheckEquality,
useArrayUnion,
useArrayRemove,
}: IUpdateFieldOptions
) => {
const updateRowDb = get(_updateRowDbAtom);
@@ -367,8 +375,36 @@ export const updateFieldAtom = atom(
_set(update, fieldName, value);
}
const localUpdate = cloneDeep(update);
const dbUpdate = cloneDeep(update);
// apply arrayUnion
if (useArrayUnion) {
if (!Array.isArray(update[fieldName]))
throw new Error("Field must be an array");
// use basic array merge on local row value
localUpdate[fieldName] = [
...(row[fieldName] ?? []),
...localUpdate[fieldName],
];
dbUpdate[fieldName] = arrayUnion(...dbUpdate[fieldName]);
}
//apply arrayRemove
if (useArrayRemove) {
if (!Array.isArray(update[fieldName]))
throw new Error("Field must be an array");
// use basic array filter on local row value
localUpdate[fieldName] = filter(
row[fieldName] ?? [],
(el) => !find(localUpdate[fieldName], el)
);
dbUpdate[fieldName] = arrayRemove(...dbUpdate[fieldName]);
}
// Check for required fields
const newRowValues = updateRowData(cloneDeep(row), update);
const newRowValues = updateRowData(cloneDeep(row), dbUpdate);
const requiredFields = ignoreRequiredFields
? []
: tableColumnsOrdered
@@ -383,7 +419,7 @@ export const updateFieldAtom = atom(
set(tableRowsLocalAtom, {
type: "update",
path,
row: update,
row: localUpdate,
deleteFields: deleteField ? [fieldName] : [],
});
@@ -403,7 +439,7 @@ export const updateFieldAtom = atom(
else {
await updateRowDb(
row._rowy_ref.path,
omitRowyFields(update),
omitRowyFields(dbUpdate),
deleteField ? [fieldName] : []
);
}

View File

@@ -238,3 +238,6 @@ export type AuditChangeFunction = (
* @param data - Optional additional data to log
*/
export const auditChangeAtom = atom<AuditChangeFunction | undefined>(undefined);
/** Store total number of rows in firestore collection */
export const serverDocCountAtom = atom(0)

View File

@@ -121,6 +121,12 @@ export const importAirtableAtom = atom<{
tableId: string;
}>({ airtableData: null, apiKey: "", baseId: "", tableId: "" });
/** Store side drawer open state */
export const sideDrawerAtom = atomWithHash<"table-information" | null>(
"sideDrawer",
null,
{ replaceState: true }
);
/** Store side drawer open state */
export const sideDrawerOpenAtom = atom(false);

View File

@@ -1,4 +1,8 @@
import { useEffect } from "react";
import { useSetAtom } from "jotai";
import { useAtomsDebugValue } from "jotai/devtools";
import useMemoValue from "use-memo-value";
import { isEqual } from "lodash-es";
export function DebugAtoms(
options: NonNullable<Parameters<typeof useAtomsDebugValue>[0]>
@@ -6,3 +10,26 @@ export function DebugAtoms(
useAtomsDebugValue(options);
return null;
}
/**
* Sets an atoms value when the `value` prop changes.
* Useful when setting an atoms initialValue and you want to keep it in sync.
*/
export function SyncAtomValue<T>({
atom,
scope,
value,
}: {
atom: Parameters<typeof useSetAtom>[0];
scope: Parameters<typeof useSetAtom>[1];
value: T;
}) {
const memoized = useMemoValue(value, isEqual);
const setAtom = useSetAtom(atom, scope);
useEffect(() => {
setAtom(memoized);
}, [setAtom, memoized]);
return null;
}

View File

@@ -71,6 +71,8 @@ export default function CircularProgressTimed({
sx={{
position: "absolute",
inset: size * 0.33 * 0.5,
width: size * 0.67,
height: size * 0.67,
"& .tick": {
stroke: (theme) => theme.palette.success.main,

View File

@@ -15,6 +15,8 @@ import useMonacoCustomizations, {
} from "./useMonacoCustomizations";
import FullScreenButton from "@src/components/FullScreenButton";
import { spreadSx } from "@src/utils/ui";
import githubLightTheme from "@src/components/CodeEditor/github-light-default.json";
import githubDarkTheme from "@src/components/CodeEditor/github-dark-default.json";
export interface IDiffEditorProps
extends Partial<DiffEditorProps>,
@@ -73,7 +75,12 @@ export default function DiffEditor({
loading={<CircularProgressOptical size={20} sx={{ m: 2 }} />}
className="editor"
{...props}
beforeMount={(monaco) => {
monaco.editor.defineTheme("github-light", githubLightTheme as any);
monaco.editor.defineTheme("github-dark", githubDarkTheme as any);
}}
onMount={handleEditorMount}
theme={`github-${theme.palette.mode}`}
options={
{
readOnly: disabled,

View File

@@ -7,6 +7,7 @@ import {
ListItemIcon,
ListItemText,
Typography,
Divider,
} from "@mui/material";
import FilterIcon from "@mui/icons-material/FilterList";
import LockOpenIcon from "@mui/icons-material/LockOpen";
@@ -50,12 +51,18 @@ import {
columnModalAtom,
tableFiltersPopoverAtom,
tableNextPageAtom,
tableSchemaAtom,
} from "@src/atoms/tableScope";
import { FieldType } from "@src/constants/fields";
import { getFieldProp } from "@src/components/fields";
import { analytics, logEvent } from "@src/analytics";
import { formatSubTableName, getTableSchemaPath } from "@src/utils/table";
import {
formatSubTableName,
getTableBuildFunctionPathname,
getTableSchemaPath,
} from "@src/utils/table";
import { runRoutes } from "@src/constants/runRoutes";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
export interface IMenuModalProps {
name: string;
@@ -91,6 +98,8 @@ export default function ColumnMenu() {
tableScope
);
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const snackLogContext = useSnackLogContext();
const [altPress] = useAtom(altPressAtom, projectScope);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
@@ -117,8 +126,42 @@ export default function ColumnMenu() {
const userDocHiddenFields =
userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? [];
let referencedColumns: string[] = [];
let referencedExtensions: string[] = [];
Object.entries(tableSchema?.columns ?? {}).forEach(([key, c], index) => {
if (
c.config?.listenerFields?.includes(column.key) ||
c.config?.requiredFields?.includes(column.key)
) {
referencedColumns.push(c.name);
}
});
tableSchema?.extensionObjects?.forEach((extension) => {
if (extension.requiredFields.includes(column.key)) {
referencedExtensions.push(extension.name);
}
});
const requireRebuild =
referencedColumns.length || referencedExtensions.length;
const handleDeleteColumn = () => {
deleteColumn(column.key);
if (requireRebuild) {
snackLogContext.requestSnackLog();
rowyRun({
route: runRoutes.buildFunction,
body: {
tablePath: tableSettings.collection,
// pathname must match old URL format
pathname: getTableBuildFunctionPathname(
tableSettings.id,
tableSettings.tableType
),
tableConfigPath: getTableSchemaPath(tableSettings),
},
});
logEvent(analytics, "deployed_extensions");
}
logEvent(analytics, "delete_column", { type: column.type });
handleClose();
};
@@ -360,8 +403,8 @@ export default function ColumnMenu() {
icon: <ColumnRemoveIcon />,
onClick: altPress
? handleDeleteColumn
: () =>
confirm({
: () => {
return confirm({
title: "Delete column?",
body: (
<>
@@ -373,12 +416,39 @@ export default function ColumnMenu() {
<Typography sx={{ mt: 1 }}>
Key: <code style={{ userSelect: "all" }}>{column.key}</code>
</Typography>
{requireRebuild ? (
<>
<Divider sx={{ my: 2 }} />
{referencedColumns.length ? (
<Typography sx={{ mt: 1 }}>
This column will be removed as a dependency of the
following columns:{" "}
<Typography fontWeight="bold" component="span">
{referencedColumns.join(", ")}
</Typography>
</Typography>
) : null}
{referencedExtensions.length ? (
<Typography sx={{ mt: 1 }}>
This column will be removed as a dependency from the
following Extensions:{" "}
<Typography fontWeight="bold" component="span">
{referencedExtensions.join(", ")}
</Typography>
</Typography>
) : null}
<Typography sx={{ mt: 1, fontWeight: "bold" }}>
You need to re-deploy this tables cloud function.
</Typography>
</>
) : null}
</>
),
confirm: "Delete",
confirm: requireRebuild ? "Delete & re-deploy" : "Delete",
confirmColor: "error",
handleConfirm: handleDeleteColumn,
}),
});
},
color: "error" as "error",
},
];

View File

@@ -36,7 +36,7 @@ export default function NewColumnModal({
const [columnLabel, setColumnLabel] = useState("");
const [fieldKey, setFieldKey] = useState("");
const [type, setType] = useState(FieldType.shortText);
const [type, setType] = useState("" as any);
const requireConfiguration = getFieldProp("requireConfiguration", type);
const isAuditField = AUDIT_FIELD_TYPES.includes(type);

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useAtom } from "jotai";
import {
@@ -10,8 +10,8 @@ import {
TextField,
Button,
} from "@mui/material";
import MemoizedText from "@src/components/Modal/MemoizedText";
import { FadeTransitionMui } from "@src/components/Modal/FadeTransition";
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
export interface IConfirmDialogProps {
@@ -49,10 +49,15 @@ export default function ConfirmDialog({
const handleClose = () => {
setState({ open: false });
setDryText("");
setDisableConfirm(false);
};
const [dryText, setDryText] = useState("");
const [disableConfirm, setDisableConfirm] = useState(
Boolean(confirmationCommand)
);
useEffect(() => {
setDisableConfirm(Boolean(confirmationCommand));
}, [confirmationCommand]);
return (
<Dialog
@@ -62,65 +67,74 @@ export default function ConfirmDialog({
else handleClose();
}}
maxWidth={maxWidth}
TransitionComponent={FadeTransitionMui}
sx={{ cursor: "default", zIndex: (theme) => theme.zIndex.modal + 50 }}
>
<DialogTitle>{title}</DialogTitle>
<>
<MemoizedText>
<DialogTitle>{title}</DialogTitle>
</MemoizedText>
<DialogContent>
{typeof body === "string" ? (
<DialogContentText>{body}</DialogContentText>
) : (
body
)}
{confirmationCommand && (
<TextField
value={dryText}
onChange={(e) => setDryText(e.target.value)}
autoFocus
label={`Type “${confirmationCommand}” below to continue:`}
placeholder={confirmationCommand}
fullWidth
id="dryText"
sx={{ mt: 3 }}
/>
)}
</DialogContent>
<MemoizedText>
<DialogContent>
{typeof body === "string" ? (
<DialogContentText>{body}</DialogContentText>
) : (
body
)}
<DialogActions
sx={[
buttonLayout === "vertical" && {
flexDirection: "column",
alignItems: "stretch",
"& > :not(:first-of-type)": { ml: 0, mt: 1 },
},
]}
>
{!hideCancel && (
<Button
onClick={() => {
if (handleCancel) handleCancel();
handleClose();
}}
>
{cancel}
</Button>
)}
<Button
onClick={() => {
if (handleConfirm) handleConfirm();
handleClose();
}}
color={confirmColor || "primary"}
variant="contained"
autoFocus
disabled={
confirmationCommand ? dryText !== confirmationCommand : false
}
{confirmationCommand && (
<TextField
onChange={(e) =>
setDisableConfirm(e.target.value !== confirmationCommand)
}
label={`Type “${confirmationCommand}” below to continue:`}
placeholder={confirmationCommand}
fullWidth
id="dryText"
sx={{ mt: 3 }}
/>
)}
</DialogContent>
</MemoizedText>
<DialogActions
sx={[
buttonLayout === "vertical" && {
flexDirection: "column",
alignItems: "stretch",
"& > :not(:first-of-type)": { ml: 0, mt: 1 },
},
]}
>
{confirm}
</Button>
</DialogActions>
<MemoizedText>
{!hideCancel && (
<Button
onClick={() => {
if (handleCancel) handleCancel();
handleClose();
}}
>
{cancel}
</Button>
)}
</MemoizedText>
<MemoizedText key={disableConfirm.toString()}>
<Button
onClick={() => {
if (handleConfirm) handleConfirm();
handleClose();
}}
color={confirmColor || "primary"}
variant="contained"
autoFocus
disabled={disableConfirm}
>
{confirm}
</Button>
</MemoizedText>
</DialogActions>
</>
</Dialog>
);
}

View File

@@ -1,19 +1,17 @@
import { useState, useEffect } from "react";
import { FallbackProps } from "react-error-boundary";
import { useLocation, Link } from "react-router-dom";
import { useLocation } from "react-router-dom";
import useOffline from "@src/hooks/useOffline";
import { Typography, Button } from "@mui/material";
import ReloadIcon from "@mui/icons-material/Refresh";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { Tables as TablesIcon } from "@src/assets/icons";
import OfflineIcon from "@mui/icons-material/CloudOff";
import EmptyState, { IEmptyStateProps } from "@src/components/EmptyState";
import AccessDenied from "@src/components/AccessDenied";
import { ROUTES } from "@src/constants/routes";
import meta from "@root/package.json";
export const ERROR_TABLE_NOT_FOUND = "Table not found";
import { EXTERNAL_LINKS } from "@src/constants/externalLinks";
export interface IErrorFallbackProps extends FallbackProps, IEmptyStateProps {}
@@ -22,6 +20,8 @@ export function ErrorFallbackContents({
resetErrorBoundary,
...props
}: IErrorFallbackProps) {
const isOffline = useOffline();
if ((error as any).code === "permission-denied")
return (
<AccessDenied error={error} resetErrorBoundary={resetErrorBoundary} />
@@ -33,14 +33,28 @@ export function ErrorFallbackContents({
<>
<Typography variant="inherit" style={{ whiteSpace: "pre-line" }}>
{(error as any).code && <b>{(error as any).code}: </b>}
{(error as any).status && <b>{(error as any).status}: </b>}
{error.message}
</Typography>
<Button
size={props.basic ? "small" : "medium"}
href={
meta.repository.url.replace(".git", "") +
"/issues/new?labels=bug&template=bug_report.md&title=Error: " +
error.message.replace("\n", " ")
EXTERNAL_LINKS.gitHub +
"/discussions/new?" +
new URLSearchParams({
labels: "bug",
category: "support-q-a",
title: [
"Error",
(error as any).code,
(error as any).status,
error.message,
]
.filter(Boolean)
.join(": ")
.replace(/\n/g, " "),
body: "👉 **Please describe the steps that you took that led to this bug.**",
}).toString()
}
target="_blank"
rel="noopener noreferrer"
@@ -52,50 +66,44 @@ export function ErrorFallbackContents({
),
};
if (error.message.startsWith(ERROR_TABLE_NOT_FOUND)) {
renderProps = {
message: ERROR_TABLE_NOT_FOUND,
description: (
<>
<Typography variant="inherit">
Make sure you have the right ID
</Typography>
<code>{error.message.replace(ERROR_TABLE_NOT_FOUND + ": ", "")}</code>
<Button
size={props.basic ? "small" : "medium"}
variant="outlined"
color="secondary"
component={Link}
to={ROUTES.tables}
startIcon={<TablesIcon />}
onClick={() => resetErrorBoundary()}
>
All tables
</Button>
</>
),
};
}
if (error.message.startsWith("Loading chunk")) {
renderProps = {
Icon: ReloadIcon,
message: "New update available",
description: (
<>
<Typography variant="inherit">
Reload this page to get the latest update
</Typography>
if (isOffline) {
renderProps = { Icon: OfflineIcon, message: "Youre offline" };
} else {
renderProps = {
Icon: ReloadIcon,
message: "Update available",
description: (
<Button
size={props.basic ? "small" : "medium"}
variant="outlined"
color="secondary"
startIcon={<ReloadIcon />}
onClick={() => window.location.reload()}
sx={{ mt: 1 }}
>
Reload
</Button>
</>
),
};
}
}
if (error.message.includes("Failed to fetch")) {
renderProps = {
Icon: OfflineIcon,
message: "Youre offline",
description: isOffline ? null : (
<Button
size={props.basic ? "small" : "medium"}
variant="outlined"
color="secondary"
startIcon={<ReloadIcon />}
onClick={() => window.location.reload()}
sx={{ mt: 1 }}
>
Reload
</Button>
),
};
}

View File

@@ -1,76 +0,0 @@
import { forwardRef, cloneElement } from "react";
import { useTheme } from "@mui/material";
import { Transition } from "react-transition-group";
import { TransitionProps } from "react-transition-group/Transition";
import { TransitionProps as MuiTransitionProps } from "@mui/material/transitions";
export const FadeTransition: React.ForwardRefExoticComponent<
Pick<TransitionProps, React.ReactText> & React.RefAttributes<any>
> = forwardRef(
({ children, ...props }: TransitionProps, ref: React.Ref<any>) => {
const theme = useTheme();
if (!children) return null;
const defaultStyle = {
opacity: 0,
transform: "scale(0.8)",
transition: theme.transitions.create(["transform", "opacity"], {
duration: "300ms",
easing: theme.transitions.easing.strong,
}),
};
const transitionStyles = {
entering: {
willChange: "transform, opacity",
},
entered: {
opacity: 1,
transform: "none",
},
exiting: {
opacity: 0,
transform: "scale(0.8)",
transitionDuration: theme.transitions.duration.leavingScreen,
},
exited: {
opacity: 0,
transform: "none",
transition: "none",
},
unmounted: {},
};
return (
<Transition
appear
timeout={{ enter: 0, exit: theme.transitions.duration.leavingScreen }}
{...props}
>
{(state) =>
cloneElement(children as any, {
style: { ...defaultStyle, ...transitionStyles[state] },
tabIndex: -1,
ref,
})
}
</Transition>
);
}
);
export default FadeTransition;
export const FadeTransitionMui = forwardRef(function Transition(
props: MuiTransitionProps & { children?: React.ReactElement<any, any> },
ref: React.Ref<unknown>
) {
return <FadeTransition ref={ref} {...props} />;
});

View File

@@ -1,12 +1,12 @@
import { memo } from "react";
import { memo, PropsWithChildren } from "react";
/**
* Used for global Modals that can have customizable text
* so that the default text doesnt appear as the modal closes.
*/
const MemoizedText = memo(
function MemoizedTextComponent({ text }: { text: React.ReactNode }) {
return <>{text}</>;
function MemoizedTextComponent({ children }: PropsWithChildren<{}>) {
return <>{children}</>;
},
() => true
);

View File

@@ -11,12 +11,10 @@ import {
DialogActions,
Button,
ButtonProps,
Slide,
} from "@mui/material";
import LoadingButton, { LoadingButtonProps } from "@mui/lab/LoadingButton";
import CloseIcon from "@mui/icons-material/Close";
import { FadeTransitionMui } from "./FadeTransition";
import ScrollableDialogContent, {
IScrollableDialogContentProps,
} from "./ScrollableDialogContent";
@@ -86,8 +84,6 @@ export default function Modal({
onClose={handleClose}
fullWidth
fullScreen={fullScreen}
TransitionComponent={fullScreen ? Slide : FadeTransitionMui}
TransitionProps={fullScreen ? ({ direction: "up" } as any) : undefined}
aria-labelledby="modal-title"
{...props}
sx={

View File

@@ -0,0 +1,91 @@
import { forwardRef, cloneElement } from "react";
import { useTheme, Slide } from "@mui/material";
import { Transition } from "react-transition-group";
import { TransitionProps } from "react-transition-group/Transition";
import { TransitionProps as MuiTransitionProps } from "@mui/material/transitions";
export const ModalTransition: React.ForwardRefExoticComponent<
Pick<TransitionProps, React.ReactText> & React.RefAttributes<any>
> = forwardRef(function ModalTransition(
{ children, ...props }: TransitionProps,
ref: React.Ref<any>
) {
const theme = useTheme();
if (!children) return null;
const isFullScreenDialog = (
Array.isArray(children) ? children[0] : children
).props?.children?.props?.className?.includes("MuiDialog-paperFullScreen");
if (isFullScreenDialog)
return (
<Slide direction="up" appear {...props}>
{children as any}
</Slide>
);
const defaultStyle = {
opacity: 0,
transform: "scale(0.8)",
transition: theme.transitions.create(["transform", "opacity"], {
duration: theme.transitions.duration.enteringScreen,
easing: theme.transitions.easing.strong,
}),
};
const transitionStyles = {
entering: {
willChange: "transform, opacity",
},
entered: {
opacity: 1,
transform: "none",
},
exiting: {
opacity: 0,
transform: "scale(0.8)",
transitionDuration: theme.transitions.duration.leavingScreen,
},
exited: {
opacity: 0,
transform: "none",
transition: "none",
},
unmounted: {},
};
return (
<Transition
appear
timeout={{
enter: theme.transitions.duration.enteringScreen,
exit: theme.transitions.duration.leavingScreen,
}}
{...props}
>
{(state) =>
cloneElement(children as any, {
style: { ...defaultStyle, ...transitionStyles[state] },
tabIndex: -1,
ref,
})
}
</Transition>
);
});
export default ModalTransition;
export const ModalTransitionMui = forwardRef(function Transition(
props: MuiTransitionProps & { children?: React.ReactElement<any, any> },
ref: React.Ref<unknown>
) {
return <ModalTransition ref={ref} {...props} />;
});

View File

@@ -6,10 +6,12 @@ import {
Button,
DialogContentText,
Link as MuiLink,
Box,
} from "@mui/material";
import CheckIcon from "@mui/icons-material/CheckCircle";
import Modal from "@src/components/Modal";
import Logo from "@src/assets/LogoRowyRun";
import MemoizedText from "@src/components/Modal/MemoizedText";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import {
@@ -43,45 +45,73 @@ export default function RowyRunModal() {
open={rowyRunModal.open}
onClose={handleClose}
title={
<Logo
size={2}
style={{
margin: "16px auto",
display: "block",
position: "relative",
right: 44 / -2,
}}
/>
<MemoizedText>
{rowyRunModal.feature
? `${
showUpdateModal ? "Update" : "Set up"
} Cloud Functions to use ${rowyRunModal.feature}`
: `Your Cloud isnt set up`}
</MemoizedText>
}
maxWidth="xs"
body={
<>
<Typography variant="h5" paragraph align="center">
{showUpdateModal ? "Update" : "Set up"} Rowy Run to use{" "}
{rowyRunModal.feature || "this feature"}
</Typography>
{showUpdateModal && (
<DialogContentText variant="body1" paragraph textAlign="center">
<DialogContentText variant="button" paragraph>
{rowyRunModal.feature || "This feature"} requires Rowy Run v
{rowyRunModal.version} or later.
</DialogContentText>
)}
<DialogContentText variant="body1" paragraph textAlign="center">
Rowy Run is a Cloud Run instance that provides backend
functionality, such as table action scripts, user management, and
easy Cloud Function deployment.{" "}
<MuiLink
href={WIKI_LINKS.rowyRun}
target="_blank"
rel="noopener noreferrer"
>
Learn more
<InlineOpenInNewIcon />
</MuiLink>
<DialogContentText paragraph>
Cloud Functions are free to use in our Base plan, you just need to
set a few things up first. Enable Cloud Functions for:
</DialogContentText>
<Box
component="ol"
sx={{
margin: 0,
padding: 0,
alignSelf: "stretch",
"& li": {
listStyleType: "none",
display: "flex",
gap: 1,
marginBottom: 2,
"& svg": {
display: "flex",
fontSize: "1.25rem",
color: "action.active",
},
},
}}
>
<li>
<CheckIcon />
Derivative fields, Extensions, Webhooks
</li>
<li>
<CheckIcon />
Table and Action scripts
</li>
<li>
<CheckIcon />
Easy Cloud Function deployment
</li>
</Box>
<MuiLink
href={WIKI_LINKS.rowyRun}
target="_blank"
rel="noopener noreferrer"
sx={{ display: "flex", mb: 3 }}
>
Learn more
<InlineOpenInNewIcon />
</MuiLink>
<Button
component={Link}
to={ROUTES.projectSettings + "#rowyRun"}
@@ -92,7 +122,7 @@ export default function RowyRunModal() {
style={{ display: "flex" }}
disabled={!userRoles.includes("ADMIN")}
>
Set up Rowy Run
Set up Cloud Functions
</Button>
{!userRoles.includes("ADMIN") && (
@@ -102,7 +132,7 @@ export default function RowyRunModal() {
color="error"
sx={{ mt: 1 }}
>
Contact the project owner to set up Rowy&nbsp;Run
Only admins can set up Cloud Functions
</Typography>
)}
</>

View File

@@ -12,7 +12,9 @@ export default function Authentication({
publicSettings,
updatePublicSettings,
}: IProjectSettingsChildProps) {
const [signInOptions, setSignInOptions] = useState(
const [signInOptions, setSignInOptions] = useState<
NonNullable<typeof publicSettings["signInOptions"]>
>(
Array.isArray(publicSettings?.signInOptions)
? publicSettings.signInOptions
: ["google"]
@@ -23,10 +25,12 @@ export default function Authentication({
<MultiSelect
label="Sign-in options"
value={signInOptions}
options={Object.keys(authOptions).map((option) => ({
value: option,
label: startCase(option).replace("Github", "GitHub"),
}))}
options={
Object.keys(authOptions).map((option) => ({
value: option,
label: startCase(option).replace("Github", "GitHub"),
})) as any
}
onChange={setSignInOptions}
onClose={() => updatePublicSettings({ signInOptions })}
multiple

View File

@@ -13,8 +13,8 @@ export default function Customization({
updatePublicSettings,
}: IProjectSettingsChildProps) {
const [customizedThemeColor, setCustomizedThemeColor] = useState(
publicSettings.theme?.light?.palette?.primary?.main ||
publicSettings.theme?.dark?.palette?.primary?.main
(publicSettings.theme?.light?.palette?.primary as any)?.main ||
(publicSettings.theme?.dark?.palette?.primary as any)?.main
);
const handleSave = ({ light, dark }: { light: string; dark: string }) => {
@@ -50,8 +50,12 @@ export default function Customization({
<Collapse in={customizedThemeColor} style={{ marginTop: 0 }}>
<Suspense fallback={<Loading />}>
<ThemeColorPicker
currentLight={publicSettings.theme?.light?.palette?.primary?.main}
currentDark={publicSettings.theme?.dark?.palette?.primary?.main}
currentLight={
(publicSettings.theme?.light?.palette?.primary as any)?.main
}
currentDark={
(publicSettings.theme?.dark?.palette?.primary as any)?.main
}
handleSave={handleSave}
/>
</Suspense>

View File

@@ -23,12 +23,12 @@ import {
projectSettingsAtom,
rowyRunAtom,
rowyRunModalAtom,
UserSettings,
updateUserAtom,
confirmDialogAtom,
} from "@src/atoms/projectScope";
import { runRoutes } from "@src/constants/runRoutes";
import { USERS } from "@src/config/dbPaths";
import type { UserSettings } from "@src/types/settings";
export default function UserItem({
_rowy_ref,

View File

@@ -9,19 +9,19 @@ export default function Account({ settings }: IUserSettingsChildProps) {
return (
<Grid container spacing={2} alignItems="center">
<Grid item>
<Avatar src={settings.user.photoURL} />
<Avatar src={settings.user?.photoURL} />
</Grid>
<Grid item xs>
<Typography variant="body1" style={{ userSelect: "all" }}>
{settings.user.displayName}
{settings.user?.displayName}
</Typography>
<Typography
variant="body2"
color="textSecondary"
style={{ userSelect: "all" }}
>
{settings.user.email}
{settings.user?.email}
</Typography>
</Grid>

View File

@@ -14,8 +14,8 @@ export default function Personalization({
}: IUserSettingsChildProps) {
const [customizedThemeColor, setCustomizedThemeColor] = useState(
Boolean(
settings.theme?.light?.palette?.primary?.main ||
settings.theme?.dark?.palette?.primary?.main
(settings.theme?.light?.palette?.primary as any)?.main ||
(settings.theme?.dark?.palette?.primary as any)?.main
)
);
@@ -52,8 +52,10 @@ export default function Personalization({
<Collapse in={customizedThemeColor} style={{ marginTop: 0 }}>
<Suspense fallback={<Loading style={{ height: "auto" }} />}>
<ThemeColorPicker
currentLight={settings.theme?.light?.palette?.primary?.main}
currentDark={settings.theme?.dark?.palette?.primary?.main}
currentLight={
(settings.theme?.light?.palette?.primary as any)?.main
}
currentDark={(settings.theme?.dark?.palette?.primary as any)?.main}
handleSave={handleSave}
/>
</Suspense>

View File

@@ -62,7 +62,9 @@ export default function Theme({
<FormControlLabel
control={
<Checkbox
defaultChecked={Boolean(settings.theme?.dark?.palette?.darker)}
defaultChecked={Boolean(
(settings.theme?.dark?.palette as any)?.darker
)}
onChange={(e) => {
updateSettings({
theme: merge(settings.theme, {

View File

@@ -122,7 +122,9 @@ export default function SideDrawer({
)}
variant="permanent"
anchor="right"
PaperProps={{ elevation: 4, component: "aside" } as any}
PaperProps={
{ elevation: 4, component: "aside", "aria-label": "Side drawer" } as any
}
>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<div className="sidedrawer-contents">

View File

@@ -111,7 +111,7 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
disabled:
selectedColumn.editable === false ||
!row ||
cellValue ||
cellValue === undefined ||
getFieldProp("group", selectedColumn.type) === "Auditing",
onClick: altPress
? handleClearValue

View File

@@ -1,9 +1,14 @@
import { useAtom, useSetAtom } from "jotai";
import { Offline, Online } from "react-detect-offline";
import { Grid, Stack, Typography, Button, Divider } from "@mui/material";
import { Import as ImportIcon } from "@src/assets/icons";
import { AddColumn as AddColumnIcon } from "@src/assets/icons";
import {
Import as ImportIcon,
AddColumn as AddColumnIcon,
} from "@src/assets/icons";
import OfflineIcon from "@mui/icons-material/CloudOff";
import EmptyState from "@src/components/EmptyState";
import ImportData from "@src/components/TableToolbar/ImportData/ImportData";
import {
@@ -22,10 +27,14 @@ export default function EmptyTable() {
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableRows] = useAtom(tableRowsAtom, tableScope);
// const { tableState, importWizardRef, columnMenuRef } = useProjectContext();
// check if theres any rows, and if rows include fields other than rowy_ref
const hasData =
tableRows.length > 0
? tableRows.some((row) => Object.keys(row).length > 1)
: false;
let contents = <></>;
if (tableRows.length > 0) {
if (hasData) {
contents = (
<>
<div>
@@ -125,21 +134,35 @@ export default function EmptyTable() {
}
return (
<Stack
spacing={3}
justifyContent="center"
alignItems="center"
sx={{
height: `calc(100vh - ${TOP_BAR_HEIGHT}px)`,
width: "100%",
p: 2,
maxWidth: 480,
margin: "0 auto",
textAlign: "center",
}}
id="empty-table"
>
{contents}
</Stack>
<>
<Offline>
<EmptyState
role="alert"
Icon={OfflineIcon}
message="Youre offline"
description="Go online to view this tables data"
style={{ height: `calc(100vh - ${TOP_BAR_HEIGHT}px)` }}
/>
</Offline>
<Online>
<Stack
spacing={3}
justifyContent="center"
alignItems="center"
sx={{
height: `calc(100vh - ${TOP_BAR_HEIGHT}px)`,
width: "100%",
p: 2,
maxWidth: 480,
margin: "0 auto",
textAlign: "center",
}}
id="empty-table"
>
{contents}
</Stack>
</Online>
</>
);
}

View File

@@ -0,0 +1,244 @@
import { useMemo, useState } from "react";
import { format } from "date-fns";
import { find, isEqual } from "lodash-es";
import MDEditor from "@uiw/react-md-editor";
import {
Box,
IconButton,
Stack,
TextField,
Typography,
useTheme,
} from "@mui/material";
import EditIcon from "@mui/icons-material/EditOutlined";
import EditOffIcon from "@mui/icons-material/EditOffOutlined";
import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope";
import { useAtom } from "jotai";
import {
projectScope,
tablesAtom,
updateTableAtom,
userRolesAtom,
} from "@src/atoms/projectScope";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import SaveState from "@src/components/SideDrawer/SaveState";
export default function Details() {
const [userRoles] = useAtom(userRolesAtom, projectScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tables] = useAtom(tablesAtom, projectScope);
const [updateTable] = useAtom(updateTableAtom, projectScope);
const theme = useTheme();
const settings = useMemo(
() => find(tables, ["id", tableSettings.id]),
[tables, tableSettings.id]
);
const { description, details, _createdBy } = settings ?? {};
const [editDescription, setEditDescription] = useState(false);
const [localDescription, setLocalDescription] = useState(description ?? "");
const [localDetails, setLocalDetails] = useState(details ?? "");
const [editDetails, setEditDetails] = useState(false);
const [mdFullScreen, setMdFullScreen] = useState(false);
const [saveState, setSaveState] = useState<
"" | "unsaved" | "saving" | "saved"
>("");
if (!settings) {
return null;
}
const handleSave = async () => {
setSaveState("saving");
await updateTable!({
...settings,
description: localDescription,
details: localDetails,
});
setSaveState("saved");
};
const isAdmin = userRoles.includes("ADMIN");
return (
<>
<Box
sx={{
paddingTop: 3,
paddingRight: 4,
position: "fixed",
right: 0,
zIndex: 1,
}}
>
<SaveState state={saveState} />
</Box>
<Stack
gap={3}
sx={{
paddingTop: 3,
paddingRight: 3,
paddingBottom: 5,
}}
>
{/* Description */}
<Stack gap={1}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-end"
>
<Typography variant="subtitle1" component="h3">
Description
</Typography>
{isAdmin && (
<IconButton
aria-label="Edit description"
onClick={() => {
setEditDescription(!editDescription);
}}
sx={{ top: 4 }}
>
{editDescription ? <EditOffIcon /> : <EditIcon />}
</IconButton>
)}
</Stack>
{editDescription ? (
<TextField
sx={{
color: "text.secondary",
}}
autoFocus={true}
value={localDescription}
onChange={(e) => {
setLocalDescription(e.target.value);
saveState !== "unsaved" && setSaveState("unsaved");
}}
onBlur={() =>
isEqual(description, localDescription)
? setSaveState("")
: handleSave()
}
rows={2}
minRows={2}
/>
) : (
<Typography variant="body2" color="text.secondary">
{localDescription ? localDescription : "No description"}
</Typography>
)}
</Stack>
{/* Details */}
<Stack gap={1}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-end"
>
<Typography variant="subtitle1" component="h3">
Details
</Typography>
{isAdmin && (
<IconButton
aria-label="Edit details"
onClick={() => {
setEditDetails(!editDetails);
}}
sx={{ top: 4 }}
>
{editDetails ? <EditOffIcon /> : <EditIcon />}
</IconButton>
)}
</Stack>
<Box
data-color-mode={theme.palette.mode}
sx={{
color: "text.secondary",
...theme.typography.body2,
"& .w-md-editor": {
backgroundColor: `${theme.palette.action.input} !important`,
},
"& .w-md-editor-fullscreen": {
backgroundColor: `${theme.palette.background.paper} !important`,
},
"& .w-md-editor-toolbar": {
display: "flex",
gap: 1,
},
"& .w-md-editor-toolbar > ul": {
display: "flex",
alignItems: "center",
},
"& .w-md-editor-toolbar > ul:first-of-type": {
overflowX: "auto",
marginRight: theme.spacing(1),
},
"& :is(h1, h2, h3, h4, h5, h6)": {
marginY: `${theme.spacing(1.5)} !important`,
borderBottom: "none !important",
},
"& details summary": {
marginBottom: theme.spacing(1),
},
}}
>
{editDetails ? (
<MDEditor
style={{ margin: 1 }}
value={localDetails}
preview={mdFullScreen ? "live" : "edit"}
commandsFilter={(command) => {
if (command.name === "fullscreen") {
command.execute = () => setMdFullScreen(!mdFullScreen);
}
return command;
}}
textareaProps={{
autoFocus: true,
onChange: (e) => {
setLocalDetails(e.target.value ?? "");
saveState !== "unsaved" && setSaveState("unsaved");
},
onBlur: () =>
isEqual(details, localDetails)
? setSaveState("")
: handleSave(),
}}
/>
) : !localDetails ? (
<Typography variant="body2">No details</Typography>
) : (
<MDEditor.Markdown source={localDetails} />
)}
</Box>
</Stack>
{/* Table Audits */}
{_createdBy && (
<Stack>
<Typography
variant="caption"
color="text.secondary"
component="div"
style={{ whiteSpace: "normal" }}
>
Created by{" "}
<Typography variant="caption" color="text.primary">
{_createdBy.displayName}
</Typography>{" "}
on{" "}
<Typography variant="caption" color="text.primary">
{format(_createdBy.timestamp.toDate(), DATE_TIME_FORMAT)}
</Typography>
</Typography>
</Stack>
)}
</Stack>
</>
);
}

View File

@@ -0,0 +1,134 @@
import { useAtom } from "jotai";
import { RESET } from "jotai/utils";
import { ErrorBoundary } from "react-error-boundary";
import clsx from "clsx";
import {
Box,
Drawer,
drawerClasses,
IconButton,
Stack,
styled,
Typography,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { sideDrawerAtom, tableScope } from "@src/atoms/tableScope";
import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar";
import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar/TableToolbar";
import ErrorFallback from "@src/components/ErrorFallback";
import Details from "./Details";
export const DRAWER_WIDTH = 450;
export const StyledDrawer = styled(Drawer)(({ theme }) => ({
[`.${drawerClasses.root}`]: {
width: DRAWER_WIDTH,
flexShrink: 0,
whiteSpace: "nowrap",
},
[`.${drawerClasses.paper}`]: {
border: "none",
boxShadow: theme.shadows[4].replace(/, 0 (\d+px)/g, ", -$1 0"),
borderTopLeftRadius: `${(theme.shape.borderRadius as number) * 3}px`,
borderBottomLeftRadius: `${(theme.shape.borderRadius as number) * 3}px`,
width: DRAWER_WIDTH,
maxWidth: `calc(100% - 28px - ${theme.spacing(1)})`,
boxSizing: "content-box",
top: TOP_BAR_HEIGHT + TABLE_TOOLBAR_HEIGHT,
height: `calc(100% - ${TOP_BAR_HEIGHT + TABLE_TOOLBAR_HEIGHT}px)`,
".MuiDialog-paperFullScreen &": {
top:
TOP_BAR_HEIGHT +
TABLE_TOOLBAR_HEIGHT +
Number(theme.spacing(2).replace("px", "")),
height: `calc(100% - ${
TOP_BAR_HEIGHT + TABLE_TOOLBAR_HEIGHT
}px - ${theme.spacing(2)})`,
},
transition: theme.transitions.create("transform", {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.standard,
}),
zIndex: theme.zIndex.drawer - 1,
},
[`:not(.sidedrawer-open) .${drawerClasses.paper}`]: {
transform: `translateX(calc(100% - env(safe-area-inset-right)))`,
},
".sidedrawer-contents": {
height: "100%",
overflow: "hidden",
marginLeft: theme.spacing(5),
marginRight: `max(env(safe-area-inset-right), ${theme.spacing(1)})`,
marginTop: theme.spacing(2),
paddingBottom: theme.spacing(5),
},
}));
export default function SideDrawer() {
const [sideDrawer, setSideDrawer] = useAtom(sideDrawerAtom, tableScope);
// const DetailsComponent =
// userRoles.includes("ADMIN") && tableSettings.templateSettings
// ? withTemplate(Details)
// : Details;
// const DetailsComponent = Details;
const open = sideDrawer === "table-information";
return (
<StyledDrawer
className={clsx(open && "sidedrawer-open")}
open={open}
variant="permanent"
anchor="right"
PaperProps={{ elevation: 4, component: "aside" } as any}
>
<ErrorBoundary FallbackComponent={ErrorFallback}>
{open && (
<div className="sidedrawer-contents">
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
pr={3}
>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
py={1}
>
<Typography variant="h5" component="h2">
Information
</Typography>
</Stack>
<IconButton
onClick={() => setSideDrawer(RESET)}
aria-label="Close"
>
<CloseIcon />
</IconButton>
</Stack>
<Box
sx={{
height: "100%",
overflow: "auto",
}}
>
<Details />
</Box>
</div>
)}
</ErrorBoundary>
</StyledDrawer>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./TableInformationDrawer";
export { default } from "./TableInformationDrawer";

View File

@@ -107,7 +107,7 @@ export default function BuildLogsSnack({
borderRadius: 1,
zIndex: 1,
transition: (theme) => theme.transitions.create("height"),
height: expanded ? "calc(100% - 300px)" : 300,
height: expanded ? "calc(100% - 300px)" : 50,
}}
>
<Box display="flex" justifyContent="space-between" alignItems="center">
@@ -178,7 +178,7 @@ export default function BuildLogsSnack({
height={"calc(100% - 25px)"}
id="live-stream-scroll-box-snack"
>
{latestActiveLog && (
{latestActiveLog && expanded && (
<>
{logs?.map((log: any, index: number) => (
<BuildLogRow logRecord={log} index={index} key={index} />

View File

@@ -137,7 +137,11 @@ export default function Export({
<ColumnSelect
value={columns.map((x) => x.key)}
onChange={handleChange(setColumns)}
filterColumns={(column) => DOWNLOADABLE_COLUMNS.includes(column.type)}
filterColumns={(column) =>
column.type === FieldType.derivative
? DOWNLOADABLE_COLUMNS.includes(column.config?.renderFieldType)
: DOWNLOADABLE_COLUMNS.includes(column.type)
}
label="Columns to export"
labelPlural="columns"
TextFieldProps={{

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useAtom, useSetAtom } from "jotai";
import { isEqual } from "lodash-es";
import { isEqual, isUndefined } from "lodash-es";
import { ITableModalProps } from "@src/components/TableModals";
import Modal from "@src/components/Modal";
@@ -23,7 +23,6 @@ import {
} from "@src/atoms/tableScope";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import { emptyExtensionObject, IExtension, ExtensionType } from "./utils";
import { runRoutes } from "@src/constants/runRoutes";
import { analytics, logEvent } from "@src/analytics";
import {
@@ -31,6 +30,14 @@ import {
getTableBuildFunctionPathname,
} from "@src/utils/table";
import {
emptyExtensionObject,
IExtension,
ExtensionType,
IRuntimeOptions,
} from "./utils";
import RuntimeOptions from "./RuntimeOptions";
export default function ExtensionsModal({ onClose }: ITableModalProps) {
const [currentUser] = useAtom(currentUserAtom, projectScope);
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
@@ -39,12 +46,25 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope);
const currentExtensionObjects = (tableSchema.extensionObjects ??
[]) as IExtension[];
const [localExtensionsObjects, setLocalExtensionsObjects] = useState(
currentExtensionObjects
tableSchema.extensionObjects ?? []
);
const [localRuntimeOptions, setLocalRuntimeOptions] = useState(
tableSchema.runtimeOptions ?? {}
);
const errors = {
runtimeOptions: {
timeoutSeconds:
!isUndefined(localRuntimeOptions.timeoutSeconds) &&
!(
localRuntimeOptions.timeoutSeconds! > 0 &&
localRuntimeOptions.timeoutSeconds! <= 540
),
},
};
const [openMigrationGuide, setOpenMigrationGuide] = useState(false);
useEffect(() => {
if (tableSchema.sparks) setOpenMigrationGuide(true);
@@ -57,7 +77,9 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
} | null>(null);
const snackLogContext = useSnackLogContext();
const edited = !isEqual(currentExtensionObjects, localExtensionsObjects);
const edited =
!isEqual(tableSchema.extensionObjects ?? [], localExtensionsObjects) ||
!isEqual(tableSchema.runtimeOptions ?? {}, localRuntimeOptions);
const handleClose = (
_setOpen: React.Dispatch<React.SetStateAction<boolean>>
@@ -70,7 +92,8 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
cancel: "Keep",
handleConfirm: () => {
_setOpen(false);
setLocalExtensionsObjects(currentExtensionObjects);
setLocalExtensionsObjects(tableSchema.extensionObjects ?? []);
setLocalRuntimeOptions(tableSchema.runtimeOptions ?? {});
onClose();
},
});
@@ -79,15 +102,18 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
}
};
const handleSaveExtensions = async (callback?: Function) => {
const handleSave = async (callback?: Function) => {
if (updateTableSchema)
await updateTableSchema({ extensionObjects: localExtensionsObjects });
await updateTableSchema({
extensionObjects: localExtensionsObjects,
runtimeOptions: localRuntimeOptions,
});
if (callback) callback();
onClose();
};
const handleSaveDeploy = async () => {
handleSaveExtensions(() => {
handleSave(() => {
try {
snackLogContext.requestSnackLog();
rowyRun({
@@ -132,6 +158,13 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
setExtensionModal(null);
};
const handleUpdateRuntimeOptions = (update: IRuntimeOptions) => {
setLocalRuntimeOptions((runtimeOptions) => ({
...runtimeOptions,
...update,
}));
};
const handleUpdateActive = (index: number, active: boolean) => {
setLocalExtensionsObjects(
localExtensionsObjects.map((extensionObject, i) => {
@@ -217,24 +250,31 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
/>
}
children={
<ExtensionList
extensions={localExtensionsObjects}
handleUpdateActive={handleUpdateActive}
handleEdit={handleEdit}
handleDuplicate={handleDuplicate}
handleDelete={handleDelete}
/>
<>
<ExtensionList
extensions={localExtensionsObjects}
handleUpdateActive={handleUpdateActive}
handleEdit={handleEdit}
handleDuplicate={handleDuplicate}
handleDelete={handleDelete}
/>
<RuntimeOptions
runtimeOptions={localRuntimeOptions}
handleUpdate={handleUpdateRuntimeOptions}
errors={errors.runtimeOptions}
/>
</>
}
actions={{
primary: {
children: "Save & Deploy",
onClick: handleSaveDeploy,
disabled: !edited,
disabled: !edited || errors.runtimeOptions.timeoutSeconds,
},
secondary: {
children: "Save",
onClick: () => handleSaveExtensions(),
disabled: !edited,
onClick: () => handleSave(),
disabled: !edited || errors.runtimeOptions.timeoutSeconds,
},
}}
/>

View File

@@ -0,0 +1,117 @@
import { useState } from "react";
import { useAtom, useSetAtom } from "jotai";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Button,
Grid,
InputAdornment,
TextField,
Typography,
} from "@mui/material";
import { ChevronDown } from "@src/assets/icons";
import MultiSelect from "@rowy/multiselect";
import {
compatibleRowyRunVersionAtom,
projectScope,
rowyRunModalAtom,
} from "@src/atoms/projectScope";
import { IRuntimeOptions } from "./utils";
export default function RuntimeOptions({
runtimeOptions,
handleUpdate,
errors,
}: {
runtimeOptions: IRuntimeOptions;
handleUpdate: (runtimeOptions: IRuntimeOptions) => void;
errors: { timeoutSeconds: boolean };
}) {
const [compatibleRowyRunVersion] = useAtom(
compatibleRowyRunVersionAtom,
projectScope
);
const openRowyRunModal = useSetAtom(rowyRunModalAtom, projectScope);
const [expanded, setExpanded] = useState(false);
const isCompatibleRowyRun = compatibleRowyRunVersion({ minVersion: "1.6.4" });
return (
<Accordion
sx={{
padding: 0,
boxShadow: "none",
backgroundImage: "inherit",
backgroundColor: "inherit",
}}
expanded={isCompatibleRowyRun && expanded}
>
<AccordionSummary
sx={{ padding: 0 }}
expandIcon={
isCompatibleRowyRun ? (
<ChevronDown />
) : (
<Button>Update Rowy Run</Button>
)
}
onClick={() =>
isCompatibleRowyRun
? setExpanded(!expanded)
: openRowyRunModal({
version: "1.6.4",
feature: "Runtime options",
})
}
>
<Typography variant="subtitle1">Runtime options</Typography>
</AccordionSummary>
<AccordionDetails sx={{ padding: 0 }}>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<MultiSelect
label="Memory Allocated"
value={runtimeOptions.memory ?? "256MB"}
onChange={(value) => handleUpdate({ memory: value ?? "256MB" })}
multiple={false}
options={["128MB", "256MB", "512MB", "1GB", "2GB", "4GB", "8GB"]}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
value={runtimeOptions.timeoutSeconds ?? 60}
label="Timeout"
fullWidth
InputProps={{
endAdornment: (
<InputAdornment position="end">seconds</InputAdornment>
),
}}
onChange={(e) =>
!isNaN(Number(e.target.value)) &&
handleUpdate({
timeoutSeconds: Number(e.target.value),
})
}
inputProps={{
inputMode: "numeric",
}}
error={errors.timeoutSeconds}
helperText={
errors.timeoutSeconds
? "Timeout must be an integer between 1 and 540"
: "The maximum timeout that can be specified is 9 mins (540 seconds)"
}
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
);
}

View File

@@ -52,6 +52,12 @@ export interface IExtension {
trackedFields?: string[];
}
// https://firebase.google.com/docs/functions/manage-functions#set_runtime_options
export interface IRuntimeOptions {
memory?: "128MB" | "256MB" | "512MB" | "1GB" | "2GB" | "4GB" | "8GB";
timeoutSeconds?: number;
}
export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"];
const extensionBodyTemplate = {

View File

@@ -1,7 +1,10 @@
import { Typography } from "@mui/material";
import WarningIcon from "@mui/icons-material/WarningAmber";
import { TableSettings } from "@src/types/table";
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
import {
IWebhook,
ISecret,
} from "@src/components/TableModals/WebhooksModal/utils";
const requestType = [
"declare type WebHookRequest {",
@@ -81,7 +84,11 @@ export const webhookBasic = {
return true;
}`,
},
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
return (
<Typography color="text.disabled">
<WarningIcon aria-label="Warning" style={{ verticalAlign: "bottom" }} />

View File

@@ -1,7 +1,10 @@
import { Typography, Link, TextField } 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";
export const webhookSendgrid = {
name: "SendGrid",
@@ -37,7 +40,11 @@ export const webhookSendgrid = {
return true;
}`,
},
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
return (
<>
<Typography gutterBottom>

View File

@@ -1,7 +1,14 @@
import { Typography, Link, TextField } from "@mui/material";
import { Typography, Link, TextField, Alert } 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";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select";
export const webhookStripe = {
name: "Stripe",
@@ -32,21 +39,19 @@ export const webhookStripe = {
return true;
}`,
},
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
return (
<>
<Typography gutterBottom>
Get your{" "}
<Link
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener noreferrer"
variant="inherit"
>
secret key
<InlineOpenInNewIcon />
</Link>{" "}
and{" "}
Select or add your secret key in the format of{" "}
<code>
{"{" + `"publicKey":"pk_...","secretKey": "sk_..."` + "}"}
</code>{" "}
and get your{" "}
<Link
href="https://dashboard.stripe.com/webhooks"
target="_blank"
@@ -61,19 +66,44 @@ export const webhookStripe = {
Then add the secret below.
</Typography>
<TextField
id="stripe-secret-key"
label="Secret key"
value={webhookObject.auth.secretKey}
fullWidth
multiline
onChange={(e) => {
setWebhookObject({
...webhookObject,
auth: { ...webhookObject.auth, secretKey: e.target.value },
});
}}
/>
{webhookObject.auth.secretKey &&
!secrets.loading &&
!secrets.keys.includes(webhookObject.auth.secretKey) && (
<Alert severity="error" sx={{ height: "auto!important" }}>
Your previously selected key{" "}
<code>{webhookObject.auth.secretKey}</code> does not exist in
Secret Manager. Please select your key again.
</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();
}}
>
Add a key in Secret Manager
</MenuItem>
</Select>
</FormControl>
<TextField
id="stripe-signing-secret"
label="Signing key"

View File

@@ -1,7 +1,10 @@
import { Typography, Link, TextField } 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";
export const webhookTypeform = {
name: "Typeform",
@@ -75,7 +78,11 @@ export const webhookTypeform = {
return true;
}`,
},
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
return (
<>
<Typography gutterBottom>

View File

@@ -1,7 +1,10 @@
import { Typography, Link, TextField } 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";
export const webhook = {
name: "Web Form",
@@ -47,7 +50,11 @@ export const webhook = {
return true;
}`,
},
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
return (
<>
<Typography gutterBottom>

View File

@@ -1,13 +1,41 @@
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { IWebhookModalStepProps } from "./WebhookModal";
import { FormControlLabel, Checkbox, Typography } from "@mui/material";
import { webhookSchemas } from "./utils";
import {
projectIdAtom,
projectScope,
rowyRunAtom,
} from "@src/atoms/projectScope";
import { runRoutes } from "@src/constants/runRoutes";
import { webhookSchemas, ISecret } 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>
@@ -37,7 +65,8 @@ export default function Step1Endpoint({
{webhookObject.auth?.enabled &&
webhookSchemas[webhookObject.type].auth(
webhookObject,
setWebhookObject
setWebhookObject,
secrets
)}
{}
</>

View File

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

View File

@@ -0,0 +1,64 @@
import { Box, InputLabel, useTheme } from "@mui/material";
import MDEditor from "@uiw/react-md-editor";
import { useState } from "react";
export default function TableDetails({ ...props }) {
const {
field: { value, onChange },
} = props;
const theme = useTheme();
const [focused, setFocused] = useState(false);
return (
<>
<InputLabel htmlFor="table-details__md-text-area" focused={focused}>
{props.label ?? ""}
</InputLabel>
<Box
data-color-mode={theme.palette.mode}
sx={{
color: "text.secondary",
...theme.typography.body2,
"& .w-md-editor": {
backgroundColor: `${theme.palette.action.input} !important`,
},
"& .w-md-editor-fullscreen": {
backgroundColor: `${theme.palette.background.paper} !important`,
},
"& .w-md-editor-toolbar": {
display: "flex",
gap: 1,
},
"& .w-md-editor-toolbar > ul": {
display: "flex",
alignItems: "center",
},
"& .w-md-editor-toolbar > ul:first-of-type": {
overflowX: "auto",
marginRight: theme.spacing(1),
},
"& :is(h1, h2, h3, h4, h5, h6)": {
marginY: `${theme.spacing(1.5)} !important`,
borderBottom: "none !important",
},
"& details summary": {
marginBottom: theme.spacing(1),
},
}}
>
<MDEditor
style={{ margin: 1 }}
preview="live"
height={150}
value={value}
onChange={onChange}
textareaProps={{
id: "table-details__md-text-area",
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
}}
{...props}
/>
</Box>
</>
);
}

View File

@@ -38,6 +38,10 @@ import {
getTableSchemaPath,
getTableBuildFunctionPathname,
} from "@src/utils/table";
import { firebaseStorageAtom } from "@src/sources/ProjectSourceFirebase";
import { uploadTableThumbnail } from "./utils";
import TableThumbnail from "./TableThumbnail";
import TableDetails from "./TableDetails";
const customComponents = {
tableName: {
@@ -55,6 +59,12 @@ const customComponents = {
defaultValue: "",
validation: [["string"]],
},
tableThumbnail: {
component: TableThumbnail,
},
tableDetails: {
component: TableDetails,
},
};
export default function TableSettingsDialog() {
@@ -67,6 +77,7 @@ export default function TableSettingsDialog() {
const [projectRoles] = useAtom(projectRolesAtom, projectScope);
const [tables] = useAtom(tablesAtom, projectScope);
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
const [firebaseStorage] = useAtom(firebaseStorageAtom, projectScope);
const navigate = useNavigate();
const confirm = useSetAtom(confirmDialogAtom, projectScope);
@@ -96,15 +107,26 @@ export default function TableSettingsDialog() {
if (!open) return null;
const handleSubmit = async (v: TableSettings & AdditionalTableSettings) => {
const handleSubmit = async (
v: TableSettings & AdditionalTableSettings & { thumbnailFile: File }
) => {
const {
_schemaSource,
_initialColumns,
_schema,
_suggestedRules,
thumbnailFile,
...values
} = v;
const data = { ...values };
let thumbnailURL = values.thumbnailURL;
if (thumbnailFile) {
thumbnailURL = await uploadTableThumbnail(firebaseStorage)(
values.id,
thumbnailFile
);
}
const data = { ...values, thumbnailURL };
const hasExtensions = !isEmpty(get(data, "_schema.extensionObjects"));
const hasWebhooks = !isEmpty(get(data, "_schema.webhooks"));

View File

@@ -0,0 +1,126 @@
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { IFieldComponentProps } from "@rowy/form-builder";
import { Button, Grid, IconButton, InputLabel, useTheme } from "@mui/material";
import { Upload as UploadImageIcon } from "@src/assets/icons";
import {
OpenInFull as ExpandIcon,
CloseFullscreen as CollapseIcon,
AddPhotoAlternateOutlined as NoImageIcon,
} from "@mui/icons-material";
import { IMAGE_MIME_TYPES } from "@src/components/fields/Image";
export default function TableThumbnail({ ...props }: IFieldComponentProps) {
const {
name,
useFormMethods: { setValue, getValues },
} = props;
const theme = useTheme();
const [localImage, setLocalImage] = useState<string | undefined>(
() => getValues().thumbnailURL
);
const [expanded, setExpanded] = useState(false);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const imageFile = acceptedFiles[0];
if (imageFile) {
setLocalImage(URL.createObjectURL(imageFile));
setValue(name, imageFile);
}
},
[name, setLocalImage, setValue]
);
const { getInputProps } = useDropzone({
onDrop,
multiple: false,
accept: IMAGE_MIME_TYPES,
});
return (
<Grid container>
<Grid
container
alignItems="center"
xs={expanded ? 12 : 10.5}
sx={{
marginRight: "auto",
transition: "all 0.1s",
}}
>
<InputLabel htmlFor="thumbnail-image__input">{props.label}</InputLabel>
<IconButton
component="label"
sx={{
marginLeft: "auto",
marginRight: expanded ? 0 : theme.spacing(0.5),
}}
>
<UploadImageIcon />
<input
id="thumbnail-image__input"
type="file"
hidden
{...getInputProps()}
/>
</IconButton>
</Grid>
<Grid
item
xs={expanded ? 12 : 1.5}
sx={{
marginLeft: "auto",
marginTop: expanded ? theme.spacing(1) : 0,
transition: "all 0.5s",
}}
>
<Grid
container
sx={{
position: "relative",
// 16:9 ratio
paddingBottom: "56.25%",
}}
>
<Button
disabled={!localImage}
sx={{
position: "absolute",
width: "100%",
height: "100%",
backgroundImage: `url("${localImage}")`,
backgroundSize: "cover",
backgroundPosition: "center center",
backgroundRepeat: "no-repeat",
"& > svg": {
display: localImage ? "none" : "block",
},
"&:hover": {
opacity: 0.75,
},
"&:hover > svg": {
display: "block",
},
}}
onClick={() => setExpanded(!expanded)}
>
{!localImage ? (
<NoImageIcon />
) : expanded ? (
<CollapseIcon />
) : (
<ExpandIcon />
)}
</Button>
</Grid>
</Grid>
</Grid>
);
}

View File

@@ -215,8 +215,9 @@ export const tableSettings = (
}.`,
disabled: mode === "update",
gridCols: { xs: 12, sm: 6 },
validation:
mode === "create"
validation: [
["matches", /^[^/]+$/g, "ID cannot have /"],
...(mode === "create"
? [
[
"test",
@@ -225,7 +226,8 @@ export const tableSettings = (
(value: any) => !find(tables, ["value", value]),
],
]
: [],
: []),
],
},
{
step: "display",
@@ -242,7 +244,23 @@ export const tableSettings = (
type: FieldType.paragraph,
name: "description",
label: "Description (optional)",
minRows: 2,
},
{
step: "display",
type: "tableDetails",
name: "details",
label: "Details (optional)",
},
{
step: "display",
type: "tableThumbnail",
name: "thumbnailFile",
label: "Thumbnail image (optional)",
},
{
step: "display",
type: FieldType.hidden,
name: "thumbnailURL",
},
// Step 3: Access controls

View File

@@ -0,0 +1,14 @@
import {
FirebaseStorage,
getDownloadURL,
ref,
uploadBytes,
} from "firebase/storage";
export const uploadTableThumbnail =
(storage: FirebaseStorage) => (tableId: string, imageFile: File) => {
const storageRef = ref(storage, `__thumbnails__/${tableId}`);
return uploadBytes(storageRef, imageFile).then(({ ref }) =>
getDownloadURL(ref)
);
};

View File

@@ -1,13 +1,18 @@
import { Suspense, forwardRef } from "react";
import { useAtom } from "jotai";
import { Offline, Online } from "react-detect-offline";
import { Tooltip, Typography, TypographyProps } from "@mui/material";
import SyncIcon from "@mui/icons-material/Sync";
import OfflineIcon from "@mui/icons-material/CloudOffOutlined";
import {
tableScope,
tableRowsAtom,
tableNextPageAtom,
serverDocCountAtom
} from "@src/atoms/tableScope";
import { spreadSx } from "@src/utils/ui";
const StatusText = forwardRef(function StatusText(
props: TypographyProps,
@@ -20,29 +25,53 @@ const StatusText = forwardRef(function StatusText(
color="text.disabled"
display="block"
{...props}
style={{ userSelect: "none", ...props.style }}
sx={[
{
userSelect: "none",
"& svg": {
fontSize: 20,
width: "1em",
height: "1em",
verticalAlign: "bottom",
display: "inline-block",
mr: 0.75,
},
},
...spreadSx(props.sx),
]}
/>
);
});
function LoadedRowsStatus() {
const [tableRows] = useAtom(tableRowsAtom, tableScope);
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
const loadingIcon = (
<SyncIcon
sx={{
animation: "spin-infinite 1.5s linear infinite",
"@keyframes spin-infinite": {
from: { transform: "rotate(45deg)" },
to: { transform: `rotate(${45 - 360}deg)` },
},
}}
/>
);
function LoadedRowsStatus() {
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
const [serverDocCount] = useAtom(serverDocCountAtom, tableScope)
const [tableRows] = useAtom(tableRowsAtom, tableScope)
if (tableNextPage.loading)
return <StatusText>{loadingIcon}Loading more</StatusText>;
if (tableNextPage.loading) return <StatusText>Loading more</StatusText>;
return (
<Tooltip
title={
tableNextPage.available
? "Scroll to the bottom to load more rows"
: "All rows have been loaded in this table"
}
describeChild
>
<Tooltip title="Syncing with database in realtime" describeChild>
<StatusText>
<SyncIcon style={{ transform: "rotate(45deg)" }} />
Loaded {!tableNextPage.available && "all "}
{tableRows.length} row{tableRows.length !== 1 && "s"}
{tableRows.length} {tableNextPage.available && `of ${serverDocCount}`} row{serverDocCount !== 1 && "s"}
</StatusText>
</Tooltip>
);
@@ -50,8 +79,21 @@ function LoadedRowsStatus() {
export default function SuspendedLoadedRowsStatus() {
return (
<Suspense fallback={<StatusText>Loading</StatusText>}>
<LoadedRowsStatus />
</Suspense>
<>
<Online>
<Suspense fallback={<StatusText>{loadingIcon}Loading</StatusText>}>
<LoadedRowsStatus />
</Suspense>
</Online>
<Offline>
<Tooltip title="Changes will be saved when you reconnect" describeChild>
<StatusText color="error.main">
<OfflineIcon />
Offline
</StatusText>
</Tooltip>
</Offline>
</>
);
}

View File

@@ -0,0 +1,25 @@
import { useAtom } from "jotai";
import { RESET } from "jotai/utils";
import TableToolbarButton from "@src/components/TableToolbar/TableToolbarButton";
import InfoIcon from "@mui/icons-material/InfoOutlined";
import {
sideDrawerAtom,
tableScope,
tableSettingsAtom,
} from "@src/atoms/tableScope";
export default function TableInformation() {
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [sideDrawer, setSideDrawer] = useAtom(sideDrawerAtom, tableScope);
return (
<TableToolbarButton
title="Table information"
icon={<InfoIcon />}
onClick={() => setSideDrawer(sideDrawer ? RESET : "table-information")}
disabled={!setSideDrawer || tableSettings.id.includes("/")}
/>
);
}

View File

@@ -16,6 +16,7 @@ import LoadedRowsStatus from "./LoadedRowsStatus";
import TableSettings from "./TableSettings";
import HiddenFields from "./HiddenFields";
import RowHeight from "./RowHeight";
import TableInformation from "./TableInformation";
import {
projectScope,
@@ -36,6 +37,7 @@ import { FieldType } from "@src/constants/fields";
const Filters = lazy(() => import("./Filters" /* webpackChunkName: "Filters" */));
// prettier-ignore
const ImportData = lazy(() => import("./ImportData/ImportData" /* webpackChunkName: "ImportData" */));
// prettier-ignore
const ReExecute = lazy(() => import("./ReExecute" /* webpackChunkName: "ReExecute" */));
@@ -147,6 +149,7 @@ export default function TableToolbar() {
<TableSettings />
</>
)}
<TableInformation />
<div className="end-spacer" />
</Stack>
);

View File

@@ -4,27 +4,38 @@ import { Tooltip, Button, ButtonProps } from "@mui/material";
export interface ITableToolbarButtonProps extends Partial<ButtonProps> {
title: string;
icon: React.ReactNode;
tooltip?: string;
}
export const TableToolbarButton = forwardRef(function TableToolbarButton_(
{ title, icon, ...props }: ITableToolbarButtonProps,
{ title, icon, tooltip, ...props }: ITableToolbarButtonProps,
ref: React.Ref<HTMLButtonElement>
) {
// https://mui.com/material-ui/react-tooltip/#accessibility
const tooltipIsDescription = Boolean(tooltip);
const button = (
<Button
variant="outlined"
color="secondary"
size="small"
style={{ minWidth: 40, height: 32, padding: 0 }}
{...props}
{...(tooltipIsDescription
? {
"aria-label": title, // Actual button label
title: tooltip, // Tooltip text, used to describe button e.g. why its disabled
}
: {})}
ref={ref}
>
{icon}
</Button>
);
return (
<Tooltip title={title}>
<span>
<Button
variant="outlined"
color="secondary"
size="small"
style={{ minWidth: 40, height: 32, padding: 0 }}
aria-label={title}
{...props}
ref={ref}
>
{icon}
</Button>
</span>
<Tooltip title={tooltip || title} describeChild={tooltipIsDescription}>
{props.disabled ? <span title="">{button}</span> : button}
</Tooltip>
);
});

View File

@@ -7,10 +7,9 @@ import {
Typography,
CardActions,
Button,
Box,
} from "@mui/material";
import { Go as GoIcon } from "@src/assets/icons";
import RenderedMarkdown from "@src/components/RenderedMarkdown";
import { TableSettings } from "@src/types/table";
export interface ITableCardProps extends TableSettings {
@@ -19,6 +18,7 @@ export interface ITableCardProps extends TableSettings {
}
export default function TableCard({
thumbnailURL,
section,
name,
description,
@@ -37,26 +37,43 @@ export default function TableCard({
</Typography>
</CardContent>
</CardActionArea>
<CardContent style={{ flexGrow: 1, paddingTop: 0 }}>
<Typography
color="textSecondary"
sx={{
minHeight: (theme) =>
(theme.typography.body2.lineHeight as number) * 2 + "em",
display: "flex",
flexDirection: "column",
gap: 1,
}}
component="div"
>
{description && (
<RenderedMarkdown
children={description}
//restrictionPreset="singleLine"
{thumbnailURL && (
<Box
sx={{
paddingBottom: "56.25%",
position: "relative",
backgroundColor: "action.input",
borderRadius: 1,
overflow: "hidden",
}}
>
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
backgroundImage: `url("${thumbnailURL}")`,
backgroundSize: "cover",
backgroundPosition: "center center",
backgroundRepeat: "no-repeat",
}}
/>
)}
</Typography>
</Box>
)}
{description && (
<Typography
color="textSecondary"
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
component="div"
>
{description}
</Typography>
)}
</CardContent>
<CardActions>

View File

@@ -303,7 +303,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
aria-label="Action will run"
name="isActionScript"
value={
config.isActionScript ? "actionScript" : "cloudFunction"
config.isActionScript !== false ? "actionScript" : "cloudFunction"
}
onChange={(e) =>
onChange("isActionScript")(
@@ -359,7 +359,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
</RadioGroup>
</FormControl>
{!config.isActionScript ? (
{config.isActionScript === false ? (
<TextField
id="callableName"
label="Callable name"
@@ -492,7 +492,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
</Stack>
),
},
config.isActionScript &&
config.isActionScript !== false &&
get(config, "undo.enabled") && {
id: "undo",
title: "Undo action",
@@ -559,6 +559,32 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
title: "Customization",
content: (
<>
<Stack>
<FormControlLabel
control={
<Checkbox
checked={config.customName?.enabled}
onChange={(e) =>
onChange("customName.enabled")(e.target.checked)
}
name="customName.enabled"
/>
}
label="Customize label for action"
style={{ marginLeft: -11 }}
/>
{config.customName?.enabled && (
<TextField
id="customName.actionName"
value={get(config, "customName.actionName")}
onChange={(e) =>
onChange("customName.actionName")(e.target.value)
}
label="Action name:"
className="labelHorizontal"
inputProps={{ style: { width: "10ch" } }}
></TextField>
)}
<FormControlLabel
control={
<Checkbox
@@ -572,7 +598,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
label="Customize button icons with emoji"
style={{ marginLeft: -11 }}
/>
</Stack>
{config.customIcons?.enabled && (
<Grid container spacing={2} sx={{ mt: { xs: 0, sm: -1 } }}>
<Grid item xs={12} sm={true}>

View File

@@ -10,6 +10,7 @@ import ActionFab from "./ActionFab";
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
import { sanitiseCallableName, isUrl } from "./utils";
import { getActionName } from "./TableCell"
export default function Action({
column,
@@ -60,7 +61,7 @@ export default function Action({
) : hasRan ? (
value.status
) : (
sanitiseCallableName(column.key)
sanitiseCallableName(getActionName(column))
)}
</Box>

View File

@@ -4,6 +4,14 @@ import { Stack } from "@mui/material";
import ActionFab from "./ActionFab";
import { sanitiseCallableName, isUrl } from "./utils";
import { get } from "lodash-es";
export const getActionName = (column: any) => {
const config = get(column, "config")
if (!get(config, "customName.enabled")) { return get(column, "name") }
return get(config, "customName.actionName") || get(column, "name");
};
export default function Action({
column,
@@ -29,7 +37,7 @@ export default function Action({
) : hasRan ? (
value.status
) : (
sanitiseCallableName(column.key)
sanitiseCallableName(getActionName(column))
)}
</div>

View File

@@ -0,0 +1,21 @@
import { IFilterOperator } from "@src/components/fields/types";
export const filterOperators: IFilterOperator[] = [
{
label: "is",
secondaryLabel: "==",
value: "color-equal"
},
{
label: "is not",
secondaryLabel: "!=",
value: "color-not-equal"
}
];
export const valueFormatter = (value: any) => {
if (value && value.hex) {
return value.hex.toString()
}
return "";
};

View File

@@ -7,6 +7,7 @@ import ColorIcon from "@mui/icons-material/Colorize";
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
import InlineCell from "./InlineCell";
import NullEditor from "@src/components/Table/editors/NullEditor";
import { filterOperators, valueFormatter } from "./filters";
const PopoverCell = lazy(
() => import("./PopoverCell" /* webpackChunkName: "PopoverCell-Color" */)
@@ -31,6 +32,10 @@ export const config: IFieldConfig = {
}),
TableEditor: NullEditor as any,
SideDrawerField,
filter: {
operators: filterOperators,
valueFormatter
},
csvImportParser: (value: string) => {
try {
const obj = JSON.parse(value);

View File

@@ -1,11 +1,6 @@
import { useCallback, useState } from "react";
import { ISideDrawerFieldProps } from "@src/components/fields/types";
import { useSetAtom } from "jotai";
import { format } from "date-fns";
import { useDropzone } from "react-dropzone";
import useUploader from "@src/hooks/useFirebaseStorageUploader";
import {
alpha,
ButtonBase,
@@ -15,69 +10,33 @@ import {
Chip,
} from "@mui/material";
import { Upload as UploadIcon } from "@src/assets/icons";
import { FileIcon } from ".";
import { ISideDrawerFieldProps } from "@src/components/fields/types";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
import { FileValue } from "@src/types/table";
import useFileUpload from "./useFileUpload";
import { FileIcon } from ".";
export default function File_({
column,
_rowy_ref,
value,
onChange,
onSubmit,
disabled,
}: ISideDrawerFieldProps) {
const confirm = useSetAtom(confirmDialogAtom, projectScope);
const { loading, progress, handleDelete, localFiles, dropzoneState } =
useFileUpload(_rowy_ref, column.key, { multiple: true });
const { uploaderState, upload, deleteUpload } = useUploader();
// Store a preview image locally while uploading
const [localFile, setLocalFile] = useState<string>("");
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (_rowy_ref && file) {
upload({
docRef: _rowy_ref! as any,
fieldName: column.key,
files: [file],
previousValue: value ?? [],
onComplete: (newValue) => {
onChange(newValue);
onSubmit();
setLocalFile("");
},
});
setLocalFile(file.name);
}
},
[_rowy_ref, value]
);
const handleDelete = (index: number) => {
const newValue = [...value];
const toBeDeleted = newValue.splice(index, 1);
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
onChange(newValue);
onSubmit();
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: false,
});
const { isDragActive, getRootProps, getInputProps } = dropzoneState;
return (
<>
{!disabled && (
<ButtonBase
disabled={loading}
sx={[
fieldSx,
{
@@ -101,13 +60,21 @@ export default function File_({
<Typography color="inherit" style={{ flexGrow: 1 }}>
Click to upload or drop file here
</Typography>
<UploadIcon sx={{ ml: 1, mr: 2 / 8 }} />
{loading ? (
<CircularProgressOptical
size={20}
variant={progress === 0 ? "indeterminate" : "determinate"}
value={progress}
/>
) : (
<UploadIcon sx={{ ml: 1, mr: 2 / 8 }} />
)}
</ButtonBase>
)}
<Grid container spacing={0.5} style={{ marginTop: 2 }}>
{Array.isArray(value) &&
value.map((file: FileValue, i) => (
value.map((file: FileValue) => (
<Grid item key={file.name}>
<Tooltip
title={`File last modified ${format(
@@ -128,7 +95,7 @@ export default function File_({
body: "This file cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(i),
handleConfirm: () => handleDelete(file),
})
: undefined
}
@@ -138,15 +105,18 @@ export default function File_({
</Grid>
))}
{localFile && (
<Grid item>
<Chip
icon={<FileIcon />}
label={localFile}
deleteIcon={<CircularProgressOptical size={20} color="inherit" />}
/>
</Grid>
)}
{localFiles &&
localFiles.map((file) => (
<Grid item>
<Chip
icon={<FileIcon />}
label={file.name}
deleteIcon={
<CircularProgressOptical size={20} color="inherit" />
}
/>
</Grid>
))}
</Grid>
</>
);

View File

@@ -1,9 +1,6 @@
import { useCallback } from "react";
import { IHeavyCellProps } from "@src/components/fields/types";
import { useSetAtom } from "jotai";
import { findIndex } from "lodash-es";
import { useDropzone } from "react-dropzone";
import { format } from "date-fns";
import { alpha, Stack, Grid, Tooltip, Chip, IconButton } from "@mui/material";
@@ -12,64 +9,24 @@ import ChipList from "@src/components/Table/formatters/ChipList";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
import useUploader from "@src/hooks/useFirebaseStorageUploader";
import { FileIcon } from ".";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import { FileValue } from "@src/types/table";
import useFileUpload from "./useFileUpload";
export default function File_({
column,
row,
value,
onSubmit,
disabled,
docRef,
}: IHeavyCellProps) {
const confirm = useSetAtom(confirmDialogAtom, projectScope);
const updateField = useSetAtom(updateFieldAtom, tableScope);
const { uploaderState, upload, deleteUpload } = useUploader();
const { progress, isLoading } = uploaderState;
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (file) {
upload({
docRef: docRef! as any,
fieldName: column.key,
files: [file],
previousValue: value,
onComplete: (newValue) => {
updateField({
path: docRef.path,
fieldName: column.key,
value: newValue,
});
},
});
}
},
[value]
);
const handleDelete = (ref: string) => {
const newValue = [...value];
const index = findIndex(newValue, ["ref", ref]);
const toBeDeleted = newValue.splice(index, 1);
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
onSubmit(newValue);
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: false,
});
const { loading, progress, handleDelete, localFiles, dropzoneState } =
useFileUpload(docRef, column.key, { multiple: true });
const { isDragActive, getRootProps, getInputProps } = dropzoneState;
const dropzoneProps = getRootProps();
return (
<Stack
direction="row"
@@ -112,8 +69,13 @@ export default function File_({
)}`}
>
<Chip
icon={<FileIcon />}
label={file.name}
icon={<FileIcon />}
sx={{
"& .MuiChip-label": {
lineHeight: 5 / 3,
},
}}
onClick={(e) => {
window.open(file.downloadURL);
e.stopPropagation();
@@ -123,21 +85,32 @@ export default function File_({
? undefined
: () =>
confirm({
handleConfirm: () => handleDelete(file.ref),
handleConfirm: () => handleDelete(file),
title: "Delete file?",
body: "This file cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
})
}
style={{ width: "100%" }}
/>
</Tooltip>
</Grid>
))}
{localFiles &&
localFiles.map((file) => (
<Grid item>
<Chip
icon={<FileIcon />}
label={file.name}
deleteIcon={
<CircularProgressOptical size={20} color="inherit" />
}
/>
</Grid>
))}
</ChipList>
{!isLoading ? (
{!loading ? (
!disabled && (
<IconButton
size="small"

View File

@@ -0,0 +1,87 @@
import { useCallback, useState } from "react";
import { useSetAtom } from "jotai";
import { some } from "lodash-es";
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
import useUploader from "@src/hooks/useFirebaseStorageUploader";
import { FileValue } from "@src/types/table";
import { DropzoneOptions, useDropzone } from "react-dropzone";
export default function useFileUpload(
docRef: any,
fieldName: string,
dropzoneOptions: DropzoneOptions = {}
) {
const updateField = useSetAtom(updateFieldAtom, tableScope);
const { uploaderState, upload, deleteUpload } = useUploader();
const [localFiles, setLocalFiles] = useState<File[]>([]);
const dropzoneState = useDropzone({
onDrop: async (acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
setLocalFiles(acceptedFiles);
await handleUpload(acceptedFiles);
setLocalFiles([]);
}
},
...dropzoneOptions,
});
const uploadingFiles = Object.keys(uploaderState);
const progress =
uploadingFiles.length > 0
? uploadingFiles.reduce((sum, fileName) => {
const fileState = uploaderState[fileName];
return sum + fileState.progress;
}, 0) / uploadingFiles.length
: 0;
const loading = some(
uploadingFiles,
(fileName) => uploaderState[fileName].loading
);
const handleUpload = useCallback(
async (files: File[]) => {
const { uploads, failures } = await upload({
docRef,
fieldName,
files,
});
updateField({
path: docRef.path,
fieldName,
value: uploads,
useArrayUnion: true,
});
return { uploads, failures };
},
[docRef, fieldName, updateField, upload]
);
const handleDelete = useCallback(
(file: FileValue) => {
updateField({
path: docRef.path,
fieldName,
value: [file],
useArrayRemove: true,
disableCheckEquality: true,
});
deleteUpload(file);
},
[deleteUpload, docRef, fieldName, updateField]
);
return {
localFiles,
progress,
loading,
uploaderState,
handleUpload,
handleDelete,
dropzoneState,
};
}

View File

@@ -1,10 +1,7 @@
import { ISideDrawerFieldProps } from "@src/components/fields/types";
import { useCallback, useState } from "react";
import { useMemo } from "react";
import { useSetAtom } from "jotai";
import { useDropzone } from "react-dropzone";
// TODO: GENERALIZE
import useUploader from "@src/hooks/useFirebaseStorageUploader";
import { assignIn } from "lodash-es";
import {
alpha,
@@ -20,12 +17,14 @@ import AddIcon from "@mui/icons-material/AddAPhotoOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import OpenIcon from "@mui/icons-material/OpenInNewOutlined";
import { FileValue } from "@src/types/table";
import Thumbnail from "@src/components/Thumbnail";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
import { IMAGE_MIME_TYPES } from ".";
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
import useFileUpload from "@src/components/fields/File/useFileUpload";
import { IMAGE_MIME_TYPES } from ".";
const imgSx = {
position: "relative",
@@ -84,57 +83,37 @@ export default function Image_({
column,
_rowy_ref,
value,
onChange,
onSubmit,
disabled,
}: ISideDrawerFieldProps) {
const confirm = useSetAtom(confirmDialogAtom, projectScope);
const { uploaderState, upload, deleteUpload } = useUploader();
const { progress } = uploaderState;
// Store a preview image locally while uploading
const [localImage, setLocalImage] = useState<string>("");
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const imageFile = acceptedFiles[0];
if (_rowy_ref && imageFile) {
upload({
docRef: _rowy_ref! as any,
fieldName: column.key,
files: [imageFile],
previousValue: value ?? [],
onComplete: (newValue) => {
onChange(newValue);
onSubmit();
setLocalImage("");
},
});
setLocalImage(URL.createObjectURL(imageFile));
}
},
[_rowy_ref, value]
);
const handleDelete = (index: number) => {
const newValue = [...value];
const toBeDeleted = newValue.splice(index, 1);
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
onChange(newValue);
onSubmit();
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: false,
const {
loading,
progress,
handleDelete,
uploaderState,
localFiles,
dropzoneState,
} = useFileUpload(_rowy_ref, column.key, {
multiple: true,
accept: IMAGE_MIME_TYPES,
});
const localImages = useMemo(
() =>
localFiles.map((file) =>
assignIn(file, { localURL: URL.createObjectURL(file) })
),
[localFiles]
);
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
return (
<>
{!disabled && (
<ButtonBase
disabled={loading}
sx={[
fieldSx,
{
@@ -160,14 +139,22 @@ export default function Image_({
? "Drop image here"
: "Click to upload or drop image here"}
</Typography>
<AddIcon sx={{ ml: 1, mr: 2 / 8 }} />
{loading ? (
<CircularProgressOptical
size={20}
variant={progress === 0 ? "indeterminate" : "determinate"}
value={progress}
/>
) : (
<AddIcon sx={{ ml: 1, mr: 2 / 8 }} />
)}
</ButtonBase>
)}
<Grid container spacing={1} style={{ marginTop: 0 }}>
{Array.isArray(value) &&
value.map((image, i) => (
<Grid item key={image.downloadURL}>
value.map((image: FileValue) => (
<Grid item key={image.name}>
{disabled ? (
<Tooltip title="Open">
<ButtonBase
@@ -214,7 +201,7 @@ export default function Image_({
body: "This image cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(i),
handleConfirm: () => handleDelete(image),
})
}
>
@@ -237,29 +224,38 @@ export default function Image_({
</Grid>
))}
{localImage && (
<Grid item>
<ButtonBase
sx={imgSx}
style={{ backgroundImage: `url("${localImage}")` }}
className="img"
>
<Grid
container
justifyContent="center"
alignItems="center"
sx={overlaySx}
{localImages &&
localImages.map((image) => (
<Grid item key={image.name}>
<ButtonBase
sx={imgSx}
style={{
backgroundImage: `url("${image.localURL}")`,
}}
className="img"
>
<CircularProgressOptical
color="inherit"
size={48}
variant={progress === 0 ? "indeterminate" : "determinate"}
value={progress}
/>
</Grid>
</ButtonBase>
</Grid>
)}
{uploaderState[image.name] && (
<Grid
container
justifyContent="center"
alignItems="center"
sx={overlaySx}
>
<CircularProgressOptical
color="inherit"
size={48}
variant={
uploaderState[image.name].progress === 0
? "indeterminate"
: "determinate"
}
value={uploaderState[image.name].progress}
/>
</Grid>
)}
</ButtonBase>
</Grid>
))}
</Grid>
</>
);

View File

@@ -1,9 +1,7 @@
import { useCallback, useState } from "react";
import { useMemo } from "react";
import { IHeavyCellProps } from "@src/components/fields/types";
import { useAtom, useSetAtom } from "jotai";
import { findIndex } from "lodash-es";
import { useDropzone } from "react-dropzone";
import { assignIn } from "lodash-es";
import {
alpha,
@@ -23,15 +21,11 @@ import Thumbnail from "@src/components/Thumbnail";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
import {
tableSchemaAtom,
tableScope,
updateFieldAtom,
} from "@src/atoms/tableScope";
import useUploader from "@src/hooks/useFirebaseStorageUploader";
import { IMAGE_MIME_TYPES } from "./index";
import { tableSchemaAtom, tableScope } from "@src/atoms/tableScope";
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
import { FileValue } from "@src/types/table";
import useFileUpload from "@src/components/fields/File/useFileUpload";
import { IMAGE_MIME_TYPES } from "./index";
// MULTIPLE
const imgSx = (rowHeight: number) => ({
@@ -88,57 +82,27 @@ const deleteImgHoverSx = {
export default function Image_({
column,
value,
onSubmit,
disabled,
docRef,
}: IHeavyCellProps) {
const confirm = useSetAtom(confirmDialogAtom, projectScope);
const updateField = useSetAtom(updateFieldAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const { uploaderState, upload, deleteUpload } = useUploader();
const { progress, isLoading } = uploaderState;
// Store a preview image locally while uploading
const [localImage, setLocalImage] = useState<string>("");
const { loading, progress, handleDelete, localFiles, dropzoneState } =
useFileUpload(docRef, column.key, {
multiple: true,
accept: IMAGE_MIME_TYPES,
});
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const imageFile = acceptedFiles[0];
if (imageFile) {
upload({
docRef: docRef! as any,
fieldName: column.key,
files: [imageFile],
previousValue: value,
onComplete: (newValue) => {
updateField({
path: docRef.path,
fieldName: column.key,
value: newValue,
});
setLocalImage("");
},
});
setLocalImage(URL.createObjectURL(imageFile));
}
},
[value]
const localImages = useMemo(
() =>
localFiles.map((file) =>
assignIn(file, { localURL: URL.createObjectURL(file) })
),
[localFiles]
);
const handleDelete = (index: number) => () => {
const newValue = [...value];
const toBeDeleted = newValue.splice(index, 1);
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
onSubmit(newValue);
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: false,
accept: IMAGE_MIME_TYPES,
});
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
const dropzoneProps = getRootProps();
const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT;
@@ -183,17 +147,17 @@ export default function Image_({
>
<Grid container spacing={0.5} wrap="nowrap">
{Array.isArray(value) &&
value.map((file: FileValue, i) => (
<Grid item key={file.downloadURL}>
value.map((image: FileValue) => (
<Grid item key={image.downloadURL}>
{disabled ? (
<Tooltip title="Open">
<ButtonBase
sx={imgSx(rowHeight)}
className="img"
onClick={() => window.open(file.downloadURL, "_blank")}
onClick={() => window.open(image.downloadURL, "_blank")}
>
<Thumbnail
imageUrl={file.downloadURL}
imageUrl={image.downloadURL}
size={thumbnailSize}
objectFit="contain"
sx={thumbnailSx}
@@ -224,12 +188,12 @@ export default function Image_({
body: "This image cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: handleDelete(i),
handleConfirm: () => handleDelete(image),
});
}}
>
<Thumbnail
imageUrl={file.downloadURL}
imageUrl={image.downloadURL}
size={thumbnailSize}
objectFit="contain"
sx={thumbnailSx}
@@ -249,24 +213,27 @@ export default function Image_({
</Grid>
))}
{localImage && (
<Grid item>
<Box
sx={[
imgSx(rowHeight),
{
boxShadow: (theme) =>
`0 0 0 1px ${theme.palette.divider} inset`,
},
]}
style={{ backgroundImage: `url("${localImage}")` }}
/>
</Grid>
)}
{localImages &&
localImages.map((image) => (
<Grid item>
<Box
sx={[
imgSx(rowHeight),
{
boxShadow: (theme) =>
`0 0 0 1px ${theme.palette.divider} inset`,
},
]}
style={{
backgroundImage: `url("${image.localURL}")`,
}}
/>
</Grid>
))}
</Grid>
</div>
{!isLoading ? (
{!loading ? (
!disabled && (
<IconButton
size="small"

View File

@@ -40,13 +40,14 @@ export default function Json({
const [editor, setEditor] = useAtom(jsonEditorAtom, projectScope);
const [codeValid, setCodeValid] = useState(true);
const sanitizedValue =
const baseValue =
value !== undefined && isValidJson(value)
? value
: column.config?.isArray
? []
: {};
const formattedJson = stringify(sanitizedValue, { space: 2 });
const formattedJson = stringify(baseValue, { space: 2 });
const sanitizedValue = JSON.parse(formattedJson);
if (disabled)
return (

View File

@@ -0,0 +1,28 @@
import RatingIcon from "@mui/icons-material/Star";
import RatingOutlineIcon from "@mui/icons-material/StarBorder"
import { get } from "lodash-es";
export interface IIconProps{
config: any,
isEmpty: boolean
}
export default function Icon({config, isEmpty} : IIconProps) {
if (isEmpty) {
return getStateOutline(config)
} else {
return getStateIcon(config)
}
}
const getStateIcon = (config: any) => {
// only use the config to get the custom rating icon if enabled via toggle
if (!get(config, "customIcons.enabled")) { return <RatingIcon /> }
console.log(get(config, "customIcons.rating"))
return get(config, "customIcons.rating") || <RatingIcon />;
};
const getStateOutline = (config: any) => {
if (!get(config, "customIcons.enabled")) { return <RatingOutlineIcon /> }
return get(config, "customIcons.rating") || <RatingOutlineIcon />;
}

View File

@@ -1,11 +1,11 @@
import { ISettingsProps } from "@src/components/fields/types";
import RatingIcon from "@mui/icons-material/Star";
import RatingOutlineIcon from "@mui/icons-material/StarBorder"
import { InputLabel, TextField, Grid, FormControlLabel, Checkbox, Stack } from "@mui/material";
import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import MuiRating from "@mui/material/Rating";
import { get } from "lodash-es";
import Icon from "./Icon"
export default function Settings({ onChange, config }: ISettingsProps) {
return (
@@ -22,6 +22,7 @@ export default function Settings({ onChange, config }: ISettingsProps) {
if (input > 20) { input = 20 }
onChange("max")(input);
}}
inputProps={{ min: 1, max: 20 }}
/>
</Grid>
<Grid item xs={6}>
@@ -79,9 +80,9 @@ export default function Settings({ onChange, config }: ISettingsProps) {
<MuiRating aria-label="Preview of the rating field with custom icon"
name="Preview"
onClick={(e) => e.stopPropagation()}
icon={get(config, "customIcons.rating") || <RatingIcon />}
icon={<Icon config={config} isEmpty={false} />}
size="small"
emptyIcon={get(config, "customIcons.rating") || <RatingOutlineIcon />}
emptyIcon={<Icon config={config} isEmpty={true} />}
max={get(config, "max")}
precision={get(config, "precision")}
sx={{ pt: 0.5 }}

View File

@@ -3,8 +3,9 @@ import { ISideDrawerFieldProps } from "@src/components/fields/types";
import { Grid } from "@mui/material";
import { Rating as MuiRating } from "@mui/material";
import "@mui/lab";
import { getStateIcon, getStateOutline } from "./TableCell";
import { fieldSx } from "@src/components/SideDrawer/utils";
import Icon from "./Icon"
export default function Rating({
column,
@@ -28,8 +29,8 @@ export default function Rating({
onChange(newValue);
onSubmit();
}}
icon={getStateIcon(column.config)}
emptyIcon={getStateOutline(column.config)}
icon={<Icon config={column.config} isEmpty={false}/>}
emptyIcon={<Icon config={column.config} isEmpty={true} />}
size="small"
max={max}
precision={precision}

View File

@@ -1,22 +1,9 @@
import { IHeavyCellProps } from "@src/components/fields/types";
import MuiRating from "@mui/material/Rating";
import RatingIcon from "@mui/icons-material/Star";
import RatingOutlineIcon from "@mui/icons-material/StarBorder"
import { get } from "lodash-es";
import Icon from "./Icon"
export const getStateIcon = (config: any) => {
// only use the config to get the custom rating icon if enabled via toggle
if (!get(config, "customIcons.enabled")) { return <RatingIcon /> }
return get(config, "customIcons.rating") || <RatingIcon />;
};
export const getStateOutline = (config: any) => {
if (!get(config, "customIcons.enabled")) { return <RatingOutlineIcon /> }
return get(config, "customIcons.rating") || <RatingOutlineIcon />;
}
export default function Rating({
row,
column,
@@ -42,11 +29,11 @@ export default function Rating({
name={`${row.id}-${column.key}`}
value={typeof value === "number" ? value : 0}
onClick={(e) => e.stopPropagation()}
icon={getStateIcon(column.config)}
icon={<Icon config={column.config} isEmpty={false} />}
size="small"
disabled={disabled}
onChange={(_, newValue) => onSubmit(newValue)}
emptyIcon={getStateOutline(column.config)}
emptyIcon={<Icon config={column.config} isEmpty={true} />}
max={max}
precision={precision}
sx={{ mx: -0.25 }}

View File

@@ -20,8 +20,7 @@ export const useSubTableData = (
location.pathname.split("/" + ROUTES.subTable)[0]
);
// const [searchParams] = useSearchParams();
// const parentLabels = searchParams.get("parentLabel");
// Get params from URL: /table/:tableId/subTable/:docPath/:subTableKey
let subTablePath = [
rootTablePath,
ROUTES.subTable,
@@ -29,8 +28,6 @@ export const useSubTableData = (
column.key,
].join("/");
// if (parentLabels) subTablePath += `${parentLabels ?? ""},${label ?? ""}`;
// else
subTablePath += "?parentLabel=" + encodeURIComponent(label ?? "");
return { documentCount, label, subTablePath };

View File

@@ -15,8 +15,8 @@ export const EXTERNAL_LINKS = {
twitter: "https://twitter.com/rowyio",
productHunt: "https://www.producthunt.com/products/rowy-2",
rowyRun: meta.repository.url.replace(".git", "Run"),
rowyRunGitHub: meta.repository.url.replace(".git", "Run"),
rowyRun: meta.repository.url.replace("rowy.git", "backend"),
rowyRunGitHub: meta.repository.url.replace("rowy.git", "backend"),
// prettier-ignore
rowyRunDeploy: `https://deploy.cloud.run/?git_repo=${meta.repository.url.replace(".git", "Run")}.git`,

View File

@@ -36,14 +36,10 @@ export enum ROUTES {
userSettings = "/settings/user",
projectSettings = "/settings/project",
members = "/members",
debugSettings = "/settings/debug",
debug = "/debug",
tutorial = "/tutorial",
tableTutorial = "/tutorial/table",
test = "/test",
themeTest = "/test/theme",
rowyRunTest = "/test/rowyRunTest",
}
export const ROUTE_TITLES = {
@@ -66,7 +62,7 @@ export const ROUTE_TITLES = {
[ROUTES.userSettings]: "Settings",
[ROUTES.projectSettings]: "Project Settings",
[ROUTES.members]: "Members",
[ROUTES.debugSettings]: "Debug",
[ROUTES.debug]: "Debug",
[ROUTES.tutorial]: "Tutorial",
[ROUTES.tableTutorial]: {
@@ -79,10 +75,6 @@ export const ROUTE_TITLES = {
titleTransitionProps: { style: { transformOrigin: "0 50%" } },
leftAligned: true,
},
[ROUTES.test]: "Test",
[ROUTES.themeTest]: "Theme Test",
[ROUTES.rowyRunTest]: "Rowy Run Test",
} as Record<
ROUTES,
| string

View File

@@ -16,25 +16,50 @@ import { projectScope } from "@src/atoms/projectScope";
import { firebaseStorageAtom } from "@src/sources/ProjectSourceFirebase";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import { FileValue } from "@src/types/table";
import { generateId } from "@src/utils/table";
export type UploaderState = {
export type UploadState = {
progress: number;
isLoading: boolean;
loading: boolean;
error?: string;
};
const initialState: UploaderState = { progress: 0, isLoading: false };
export type UploaderState = {
[fileName: string]: UploadState;
};
const uploadReducer = (
prevState: UploaderState,
newProps: Partial<UploaderState>
) => ({ ...prevState, ...newProps });
action: {
type: "reset" | "file_update";
data?: { fileName: string; newProps: Partial<UploadState> };
}
) => {
switch (action.type) {
case "reset":
return {};
case "file_update":
const { fileName, newProps } = action.data!;
const nextState = { ...prevState };
nextState[fileName] = {
...nextState[fileName],
...newProps,
};
return nextState;
}
};
export type UploadProps = {
docRef: DocumentReference;
fieldName: string;
files: File[];
previousValue?: FileValue[];
onComplete?: (values: FileValue[]) => void;
onComplete?: ({
uploads,
failures,
}: {
uploads: FileValue[];
failures: string[];
}) => void;
};
// TODO: GENERALIZE INTO ATOM
@@ -42,125 +67,129 @@ const useFirebaseStorageUploader = () => {
const [firebaseStorage] = useAtom(firebaseStorageAtom, projectScope);
const { enqueueSnackbar } = useSnackbar();
const [uploaderState, uploaderDispatch] = useReducer(uploadReducer, {
...initialState,
});
const [uploaderState, uploaderDispatch] = useReducer(uploadReducer, {});
const upload = ({
docRef,
fieldName,
files,
previousValue,
onComplete,
}: UploadProps) => {
uploaderDispatch({ isLoading: true });
const upload = ({ docRef, fieldName, files }: UploadProps) => {
const uploads = [] as FileValue[];
const failures = [] as string[];
const isCompleted = () => uploads.length + failures.length === files.length;
files.forEach((file) => {
const storageRef = ref(
firebaseStorage,
`${docRef.path}/${fieldName}/${file.name}`
);
const uploadTask = uploadBytesResumable(storageRef, file);
return new Promise((resolve) =>
files.forEach((file) => {
uploaderDispatch({
type: "file_update",
data: {
fileName: file.name,
newProps: { loading: true, progress: 0 },
},
});
uploadTask.on(
// event
"state_changed",
// observer
(snapshot) => {
// Get task progress, including the number of bytes uploaded and the total number of bytes to be uploaded
const progress =
(snapshot.bytesTransferred / snapshot.totalBytes) * 100;
uploaderDispatch({ progress });
console.log("Upload is " + progress + "% done");
const storageRef = ref(
firebaseStorage,
`${docRef.path}/${fieldName}/${generateId()}-${file.name}`
);
const uploadTask = uploadBytesResumable(storageRef, file, {
cacheControl: "public, max-age=31536000",
});
uploadTask.on(
// event
"state_changed",
// observer
(snapshot) => {
// Get task progress, including the number of bytes uploaded and the total number of bytes to be uploaded
const progress =
(snapshot.bytesTransferred / snapshot.totalBytes) * 100;
uploaderDispatch({
type: "file_update",
data: { fileName: file.name, newProps: { progress } },
});
},
switch (snapshot.state) {
case "paused":
console.log("Upload is paused");
break;
case "running":
console.log("Upload is running");
break;
}
},
// error  must be any to access error.code
(error: any) => {
// A full list of error codes is available at
// https://firebase.google.com/docs/storage/web/handle-errors
const errorAction = {
fileName: file.name,
newProps: { loading: false } as Partial<UploadState>,
};
switch (error.code) {
case "storage/unknown":
// Unknown error occurred, inspect error.serverResponse
enqueueSnackbar("Unknown error occurred", { variant: "error" });
errorAction.newProps.error = error.serverResponse;
break;
// error  must be any to access error.code
(error: any) => {
// A full list of error codes is available at
// https://firebase.google.com/docs/storage/web/handle-errors
switch (error.code) {
case "storage/unknown":
// Unknown error occurred, inspect error.serverResponse
enqueueSnackbar("Unknown error occurred", { variant: "error" });
uploaderDispatch({ error: error.serverResponse });
break;
case "storage/unauthorized":
// User doesn't have permission to access the object
enqueueSnackbar("You dont have permissions to upload files", {
variant: "error",
action: (
<Paper elevation={0} sx={{ borderRadius: 1 }}>
<Button
color="primary"
href={
WIKI_LINKS.setupRoles +
"#write-firebase-storage-security-rules"
}
target="_blank"
rel="noopener noreferrer"
>
Fix
<InlineOpenInNewIcon />
</Button>
</Paper>
),
});
errorAction.newProps.error = error.code;
break;
case "storage/unauthorized":
// User doesn't have permission to access the object
enqueueSnackbar("You dont have permissions to upload files", {
variant: "error",
action: (
<Paper elevation={0} sx={{ borderRadius: 1 }}>
<Button
color="primary"
href={
WIKI_LINKS.setupRoles +
"#write-firebase-storage-security-rules"
}
target="_blank"
rel="noopener noreferrer"
>
Fix
<InlineOpenInNewIcon />
</Button>
</Paper>
),
});
uploaderDispatch({ error: error.code });
break;
case "storage/canceled":
// User canceled the upload
uploaderDispatch({ error: error.code });
break;
default:
uploaderDispatch({ error: error.code });
// Unknown error occurred, inspect error.serverResponse
break;
}
uploaderDispatch({ isLoading: false });
},
// complete
() => {
uploaderDispatch({ isLoading: false });
// Upload completed successfully, now we can get the download URL
getDownloadURL(uploadTask.snapshot.ref).then(
(downloadURL: string) => {
const newValue: FileValue[] = Array.isArray(previousValue)
? [...previousValue]
: [];
newValue.push({
ref: uploadTask.snapshot.ref.fullPath,
downloadURL,
name: file.name,
type: file.type,
lastModifiedTS: file.lastModified,
});
// STore in the document if docRef provided
// if (docRef && docRef.update)docRef.update({ [fieldName]: newValue });
// Also call callback if it exists
// IMPORTANT: SideDrawer form may not update its local values after this
// function updates the doc, so you MUST update it manually
// using this callback
if (onComplete) onComplete(newValue);
case "storage/canceled":
default:
errorAction.newProps.error = error.code;
break;
}
);
}
);
failures.push(file.name);
uploaderDispatch({ type: "file_update", data: errorAction });
if (isCompleted()) resolve(true);
},
// complete
() => {
uploaderDispatch({
type: "file_update",
data: {
fileName: file.name,
newProps: { loading: false },
},
});
// Upload completed successfully, now we can get the download URL
getDownloadURL(uploadTask.snapshot.ref).then(
(downloadURL: string) => {
// Store in the document if docRef provided
// if (docRef && docRef.update)docRef.update({ [fieldName]: newValue });
// Also call callback if it exists
// IMPORTANT: SideDrawer form may not update its local values after this
// function updates the doc, so you MUST update it manually
// using this callback
const obj = {
ref: uploadTask.snapshot.ref.fullPath,
downloadURL,
name: file.name,
type: file.type,
lastModifiedTS: file.lastModified,
};
uploads.push(obj);
if (isCompleted()) resolve(true);
}
);
}
);
})
).then(() => {
uploaderDispatch({ type: "reset" });
return { uploads, failures };
});
};

View File

@@ -22,6 +22,7 @@ import {
QueryConstraint,
WhereFilterOp,
documentId,
getCountFromServer
} from "firebase/firestore";
import { useErrorHandler } from "react-error-boundary";
@@ -62,6 +63,8 @@ interface IUseFirestoreCollectionWithAtomOptions<T> {
deleteDocAtom?: PrimitiveAtom<DeleteCollectionDocFunction | undefined>;
/** Update this atom when were loading the next page, and if there is a next page available. Uses same scope as `dataScope`. */
nextPageAtom?: PrimitiveAtom<NextPageState>;
/** Set this atom's value to the number of docs in the collection on each new snapshot */
serverDocCountAtom?: PrimitiveAtom<number> | undefined;
}
/**
@@ -93,6 +96,7 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
updateDocAtom,
deleteDocAtom,
nextPageAtom,
serverDocCountAtom
} = options || {};
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
@@ -116,9 +120,10 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
void
>(nextPageAtom || (dataAtom as any), dataScope);
const setServerDocCountAtom = useSetAtom(serverDocCountAtom || (dataAtom as any), dataScope)
// Store if were at the last page to prevent a new query from being created
const [isLastPage, setIsLastPage] = useState(false);
// Create the query and memoize using Firestores queryEqual
const memoizedQuery = useMemoValue(
getQuery<T>(
@@ -190,6 +195,12 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
available: docs.length >= memoizedQuery.limit,
}));
}
// on each new snapshot, use the query to get and set the document count from the server
if (serverDocCountAtom) {
getCountFromServer(memoizedQuery.unlimitedQuery).then((value) => {
setServerDocCountAtom(value.data().count)
})
}
} catch (error) {
if (onError) onError(error as FirestoreError);
else handleError(error);
@@ -221,6 +232,7 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
handleError,
nextPageAtom,
setNextPageAtom,
setServerDocCountAtom
]);
// Create variable for validity of query to pass to useEffect dependencies
@@ -313,14 +325,13 @@ const getQuery = <T>(
}
if (!collectionRef) return null;
const limit = (page + 1) * pageSize;
const firestoreFilters = tableFiltersToFirestoreFilters(filters || []);
return {
query: query<T>(
collectionRef,
queryLimit((page + 1) * pageSize),
queryLimit(limit),
...firestoreFilters,
...(sorts?.map((order) => orderBy(order.key, order.direction)) || [])
),
@@ -328,6 +339,7 @@ const getQuery = <T>(
limit,
firestoreFilters,
sorts,
unlimitedQuery: query<T>(collectionRef, ...firestoreFilters)
};
} catch (e) {
if (onError) onError(e as FirestoreError);
@@ -376,12 +388,16 @@ export const tableFiltersToFirestoreFilters = (filters: TableFilter[]) => {
} else if (filter.operator === "id-equal") {
firestoreFilters.push(where(documentId(), "==", filter.value));
continue;
} else if (filter.operator === "color-equal") {
firestoreFilters.push(where(filter.key.concat(".hex"), "==", filter.value.hex.toString()))
continue
} else if (filter.operator === "color-not-equal") {
firestoreFilters.push(where(filter.key.concat(".hex"), "!=", filter.value.hex.toString()))
continue
}
firestoreFilters.push(
where(filter.key, filter.operator as WhereFilterOp, filter.value)
);
}
return firestoreFilters;
};

View File

@@ -85,10 +85,17 @@ export function useFirestoreDocWithAtom<T = TableRow>(
// Create a listener for the document
const unsubscribe = onSnapshot(
memoizedDocRef,
{ includeMetadataChanges: true },
(docSnapshot) => {
try {
// Create doc if it doesnt exist
if (!docSnapshot.exists() && !!createIfNonExistent) {
// Create doc if it doesnt exist and were online
// WARNING: If offline and we doc doesnt exist in cache, it will
// ovewrite with default values when we go online
if (
!docSnapshot.exists() &&
!!createIfNonExistent &&
!docSnapshot.metadata.fromCache
) {
setDoc(docSnapshot.ref, createIfNonExistent);
setDataAtom({ ...createIfNonExistent, _rowy_ref: docSnapshot.ref });
} else {
@@ -121,8 +128,6 @@ export function useFirestoreDocWithAtom<T = TableRow>(
disableSuspense,
createIfNonExistent,
handleError,
updateDataAtom,
setUpdateDataAtom,
]);
// Set updateDocAtom and deleteDocAtom values if they exist

22
src/hooks/useOffline.ts Normal file
View File

@@ -0,0 +1,22 @@
import { useState, useEffect } from "react";
export default function useOffline() {
const [isOffline, setIsOffline] = useState(false);
const handleOffline = () => setIsOffline(true);
const handleOnline = () => setIsOffline(false);
useEffect(() => {
// Need to set here because the listener doesnt fire on initial load
setIsOffline(!window.navigator.onLine);
window.addEventListener("offline", handleOffline);
window.addEventListener("online", handleOnline);
return () => {
window.removeEventListener("offline", handleOffline);
window.removeEventListener("online", handleOnline);
};
}, []);
return isOffline;
}

View File

@@ -15,10 +15,15 @@ import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar";
/**
* Lock pages for admins only
*/
export default function AdminRoute({ children }: PropsWithChildren<{}>) {
export default function AdminRoute({
children,
fallback,
}: PropsWithChildren<{ fallback?: React.ReactNode }>) {
const [userRoles] = useAtom(userRolesAtom, projectScope);
if (!userRoles.includes("ADMIN"))
if (!userRoles.includes("ADMIN")) {
if (fallback) return fallback as JSX.Element;
return (
<EmptyState
role="alert"
@@ -39,6 +44,7 @@ export default function AdminRoute({ children }: PropsWithChildren<{}>) {
style={{ marginTop: -TOP_BAR_HEIGHT, marginBottom: -TOP_BAR_HEIGHT }}
/>
);
}
return children as JSX.Element;
}

View File

@@ -62,7 +62,7 @@ export default function Navigation({ children }: React.PropsWithChildren<{}>) {
<Loading fullScreen style={{ marginTop: -TOP_BAR_HEIGHT }} />
}
>
<div style={{ flexGrow: 1, maxWidth: "100%" }}>
<div style={{ flexGrow: 1, minWidth: 0 }}>
<Outlet />
{children}
</div>

View File

@@ -0,0 +1,193 @@
import { useAtom } from "jotai";
import { useSnackbar } from "notistack";
import {
updateDoc,
doc,
terminate,
clearIndexedDbPersistence,
} from "firebase/firestore";
import { Container, Stack, Button } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import SettingsSection from "@src/components/Settings/SettingsSection";
import {
projectScope,
projectIdAtom,
projectSettingsAtom,
userRolesAtom,
allUsersAtom,
updateUserAtom,
} from "@src/atoms/projectScope";
import UserManagementSourceFirebase from "@src/sources/MembersSourceFirebase";
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
import { CONFIG, TABLE_SCHEMAS, USERS } from "@src/config/dbPaths";
import { getTableSchemaPath } from "@src/utils/table";
import { useScrollToHash } from "@src/hooks/useScrollToHash";
export default function DebugPage() {
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
const [projectId] = useAtom(projectIdAtom, projectScope);
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
const [userRoles] = useAtom(userRolesAtom, projectScope);
const [users] = useAtom(allUsersAtom, projectScope);
const [updateUser] = useAtom(updateUserAtom, projectScope);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
useScrollToHash();
return (
<Container maxWidth="sm" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
{userRoles.includes("ADMIN") && <UserManagementSourceFirebase />}
<Stack spacing={4}>
<SettingsSection title="Firestore config" transitionTimeout={0 * 100}>
<Button
href={`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${CONFIG.replace(
/\//g,
"~2F"
)}`}
target="_blank"
rel="noopener noreferrer"
color="primary"
style={{ display: "flex", width: "max-content" }}
>
Config <InlineOpenInNewIcon />
</Button>
<Button
href={`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${TABLE_SCHEMAS.replace(
/\//g,
"~2F"
)}`}
target="_blank"
rel="noopener noreferrer"
color="primary"
style={{ display: "flex", width: "max-content" }}
>
Table schemas <InlineOpenInNewIcon />
</Button>
<Button
href={`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${USERS.replace(
/\//g,
"~2F"
)}`}
target="_blank"
rel="noopener noreferrer"
color="primary"
style={{ display: "flex", width: "max-content" }}
>
Users <InlineOpenInNewIcon />
</Button>
</SettingsSection>
{userRoles.includes("ADMIN") && (
<SettingsSection
title="Reset table filters"
transitionTimeout={1 * 100}
>
<Button
onClick={async () => {
if (!updateUser)
enqueueSnackbar("Could not update user settings", {
variant: "error",
});
const loadingSnackbar = enqueueSnackbar(
"Resetting all user filters…",
{
persist: true,
}
);
try {
const promises = users.map((user) =>
updateUser!(`${USERS}/${user._rowy_ref!.id}`, {
tables: Object.entries(user.tables ?? {}).reduce(
(a, [key, table]) => {
a[key] = { ...table, filters: [] };
return a;
},
{} as Record<string, any>
),
})
);
await Promise.all(promises);
closeSnackbar(loadingSnackbar);
enqueueSnackbar("Reset all user filters", {
variant: "success",
});
} catch (e) {
enqueueSnackbar((e as Error).message, { variant: "error" });
closeSnackbar(loadingSnackbar);
}
}}
color="error"
style={{ display: "flex" }}
>
Reset all user filters
</Button>
<Button
onClick={async () => {
if (!projectSettings.tables) {
enqueueSnackbar("No tables to update");
return;
}
const loadingSnackbar = enqueueSnackbar(
"Resetting all table-level filters…",
{ persist: true }
);
try {
const promises = projectSettings.tables.map((table) =>
updateDoc(
doc(firebaseDb, getTableSchemaPath(table)),
"filters",
[]
)
);
await Promise.all(promises);
closeSnackbar(loadingSnackbar);
enqueueSnackbar("Reset all table-level filters", {
variant: "success",
});
} catch (e) {
enqueueSnackbar((e as Error).message, { variant: "error" });
closeSnackbar(loadingSnackbar);
}
}}
color="error"
style={{ display: "flex" }}
>
Reset all table-level filters
</Button>
</SettingsSection>
)}
<SettingsSection
title="Local Firestore instance"
transitionTimeout={2 * 100}
>
<Button
onClick={async () => {
enqueueSnackbar("Clearing cache. Page will reload…", {
persist: true,
});
await terminate(firebaseDb);
await clearIndexedDbPersistence(firebaseDb);
window.location.reload();
}}
color="error"
style={{ display: "flex" }}
>
Reset local Firestore cache
</Button>
</SettingsSection>
</Stack>
</Container>
);
}

View File

@@ -1,149 +0,0 @@
import { useAtom } from "jotai";
import { useSnackbar } from "notistack";
import {
updateDoc,
doc,
terminate,
clearIndexedDbPersistence,
} from "firebase/firestore";
import { Container, Stack, Button } from "@mui/material";
import SettingsSection from "@src/components/Settings/SettingsSection";
import {
projectScope,
projectSettingsAtom,
allUsersAtom,
updateUserAtom,
} from "@src/atoms/projectScope";
import UserManagementSourceFirebase from "@src/sources/MembersSourceFirebase";
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
import { USERS } from "@src/config/dbPaths";
import { getTableSchemaPath } from "@src/utils/table";
import { useScrollToHash } from "@src/hooks/useScrollToHash";
export interface IProjectSettingsChildProps {
settings: Record<string, any>;
updateSettings: (data: Record<string, any>) => void;
publicSettings: Record<string, any>;
updatePublicSettings: (data: Record<string, any>) => void;
}
export default function DebugSettingsPage() {
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
const [users] = useAtom(allUsersAtom, projectScope);
const [updateUser] = useAtom(updateUserAtom, projectScope);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
useScrollToHash();
return (
<Container maxWidth="sm" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
<UserManagementSourceFirebase />
<Stack spacing={4}>
<SettingsSection title="Reset table filters">
<Button
onClick={async () => {
if (!updateUser)
enqueueSnackbar("Could not update user settings", {
variant: "error",
});
const loadingSnackbar = enqueueSnackbar(
"Resetting all user filters…",
{
persist: true,
}
);
try {
const promises = users.map((user) =>
updateUser!(`${USERS}/${user._rowy_ref!.id}`, {
tables: Object.entries(user.tables ?? {}).reduce(
(a, [key, table]) => {
a[key] = { ...table, filters: [] };
return a;
},
{} as Record<string, any>
),
})
);
await Promise.all(promises);
closeSnackbar(loadingSnackbar);
enqueueSnackbar("Reset all user filters", {
variant: "success",
});
} catch (e) {
enqueueSnackbar((e as Error).message, { variant: "error" });
closeSnackbar(loadingSnackbar);
}
}}
color="error"
style={{ display: "flex" }}
>
Reset all user filters
</Button>
<Button
onClick={async () => {
if (!projectSettings.tables) {
enqueueSnackbar("No tables to update");
return;
}
const loadingSnackbar = enqueueSnackbar(
"Resetting all table-level filters…",
{ persist: true }
);
try {
const promises = projectSettings.tables.map((table) =>
updateDoc(
doc(firebaseDb, getTableSchemaPath(table)),
"filters",
[]
)
);
await Promise.all(promises);
closeSnackbar(loadingSnackbar);
enqueueSnackbar("Reset all table-level filters", {
variant: "success",
});
} catch (e) {
enqueueSnackbar((e as Error).message, { variant: "error" });
closeSnackbar(loadingSnackbar);
}
}}
color="error"
style={{ display: "flex" }}
>
Reset all table-level filters
</Button>
</SettingsSection>
<SettingsSection
title="Local Firestore instance"
transitionTimeout={1 * 100}
>
<Button
onClick={async () => {
enqueueSnackbar("Clearing cache. Page will reload…", {
persist: true,
});
await terminate(firebaseDb);
await clearIndexedDbPersistence(firebaseDb);
window.location.href = "/";
}}
color="error"
style={{ display: "flex" }}
>
Reset local Firestore cache
</Button>
</SettingsSection>
</Stack>
</Container>
);
}

View File

@@ -19,12 +19,13 @@ import {
updatePublicSettingsAtom,
} from "@src/atoms/projectScope";
import { useScrollToHash } from "@src/hooks/useScrollToHash";
import { ProjectSettings, PublicSettings } from "@src/types/settings";
export interface IProjectSettingsChildProps {
settings: Record<string, any>;
updateSettings: (data: Record<string, any>) => void;
publicSettings: Record<string, any>;
updatePublicSettings: (data: Record<string, any>) => void;
settings: ProjectSettings;
updateSettings: (data: Partial<ProjectSettings>) => void;
publicSettings: PublicSettings;
updatePublicSettings: (data: Partial<PublicSettings>) => void;
}
export default function ProjectSettingsPage() {

View File

@@ -18,10 +18,11 @@ import {
updateUserSettingsAtom,
} from "@src/atoms/projectScope";
import { useScrollToHash } from "@src/hooks/useScrollToHash";
import { UserSettings } from "@src/types/settings";
export interface IUserSettingsChildProps {
settings: Record<string, any>;
updateSettings: (data: Record<string, any>) => void;
settings: UserSettings;
updateSettings: (data: Partial<UserSettings>) => void;
}
export default function UserSettingsPage() {

View File

@@ -1,20 +1,25 @@
import { Suspense, useMemo } from "react";
import { Suspense } from "react";
import { useAtom, Provider } from "jotai";
import { DebugAtoms } from "@src/atoms/utils";
import { useParams, useOutlet } from "react-router-dom";
import { useParams, useOutlet, Link } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import { find, isEmpty } from "lodash-es";
import useOffline from "@src/hooks/useOffline";
import ErrorFallback, {
ERROR_TABLE_NOT_FOUND,
} from "@src/components/ErrorFallback";
import { Typography, Button } from "@mui/material";
import ErrorFallback from "@src/components/ErrorFallback";
import TableSourceFirestore from "@src/sources/TableSourceFirestore";
import TablePage from "./TablePage";
import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton";
import TableSkeleton from "@src/components/Table/TableSkeleton";
import EmptyState from "@src/components/EmptyState";
import OfflineIcon from "@mui/icons-material/CloudOff";
import { Tables as TablesIcon } from "@src/assets/icons";
import {
projectScope,
projectIdAtom,
currentUserAtom,
projectSettingsAtom,
tablesAtom,
@@ -24,6 +29,9 @@ import {
tableIdAtom,
tableSettingsAtom,
} from "@src/atoms/tableScope";
import { SyncAtomValue } from "@src/atoms/utils";
import { ROUTES } from "@src/constants/routes";
import useDocumentTitle from "@src/hooks/useDocumentTitle";
/**
* Wraps `TablePage` with the data for a top-level table.
@@ -32,11 +40,15 @@ import {
export default function ProvidedTablePage() {
const { id } = useParams();
const outlet = useOutlet();
const [projectId] = useAtom(projectIdAtom, projectScope);
const [currentUser] = useAtom(currentUserAtom, projectScope);
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
const [tables] = useAtom(tablesAtom, projectScope);
const isOffline = useOffline();
const tableSettings = find(tables, ["id", id]);
useDocumentTitle(projectId, tableSettings ? tableSettings.name : "Not found");
const tableSettings = useMemo(() => find(tables, ["id", id]), [tables, id]);
if (!tableSettings) {
if (isEmpty(projectSettings)) {
return (
@@ -46,7 +58,41 @@ export default function ProvidedTablePage() {
</>
);
} else {
throw new Error(ERROR_TABLE_NOT_FOUND + ": " + id);
if (isOffline) {
return (
<EmptyState
role="alert"
fullScreen
Icon={OfflineIcon}
message="Youre offline"
/>
);
} else {
return (
<EmptyState
role="alert"
fullScreen
message="Table not found"
description={
<>
<Typography variant="inherit">
Make sure you have the right ID
</Typography>
<code>{id}</code>
<Button
variant="outlined"
color="secondary"
component={Link}
to={ROUTES.tables}
startIcon={<TablesIcon />}
>
All tables
</Button>
</>
}
/>
);
}
}
}
@@ -70,6 +116,12 @@ export default function ProvidedTablePage() {
]}
>
<DebugAtoms scope={tableScope} />
<SyncAtomValue
atom={tableSettingsAtom}
scope={tableScope}
value={tableSettings}
/>
<TableSourceFirestore />
<Suspense
fallback={

View File

@@ -8,6 +8,7 @@ import { Fade } from "@mui/material";
import ErrorFallback, {
InlineErrorFallback,
} from "@src/components/ErrorFallback";
import TableInformationDrawer from "@src/components/TableInformationDrawer/TableInformationDrawer";
import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton";
import TableSkeleton from "@src/components/Table/TableSkeleton";
import EmptyTable from "@src/components/Table/EmptyTable";
@@ -103,6 +104,12 @@ export default function TablePage({
</Suspense>
</ErrorBoundary>
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
<Suspense fallback={null}>
<TableInformationDrawer />
</Suspense>
</ErrorBoundary>
{!disableModals && (
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
<Suspense fallback={null}>

View File

@@ -1,5 +1,6 @@
import { useAtom, useSetAtom } from "jotai";
import { find, groupBy, sortBy } from "lodash-es";
import { Link } from "react-router-dom";
import {
Container,
@@ -13,12 +14,14 @@ import {
IconButton,
Zoom,
} from "@mui/material";
import ViewListIcon from "@mui/icons-material/ViewListOutlined";
import ViewGridIcon from "@mui/icons-material/ViewModuleOutlined";
import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder";
import FavoriteIcon from "@mui/icons-material/Favorite";
import EditIcon from "@mui/icons-material/EditOutlined";
import AddIcon from "@mui/icons-material/Add";
import InfoIcon from "@mui/icons-material/InfoOutlined";
import FloatingSearch from "@src/components/FloatingSearch";
import SlideTransition from "@src/components/Modal/SlideTransition";
@@ -54,7 +57,6 @@ export default function TablesPage() {
tableSettingsDialogAtom,
projectScope
);
useScrollToHash();
const [results, query, handleQuery] = useBasicSearch(
@@ -159,6 +161,15 @@ export default function TablesPage() {
sx={view === "list" ? { p: 1.5 } : undefined}
color="secondary"
/>
<IconButton
aria-label="Table information"
size={view === "list" ? "large" : undefined}
component={Link}
to={`${getLink(table)}#sideDrawer="table-information"`}
style={{ marginLeft: 0 }}
>
<InfoIcon />
</IconButton>
</>
);

View File

@@ -13,6 +13,7 @@ import {
getTableSchemaAtom,
AdditionalTableSettings,
MinimumTableSettings,
currentUserAtom,
} from "@src/atoms/projectScope";
import { firebaseDbAtom } from "./init";
@@ -21,12 +22,14 @@ import {
TABLE_SCHEMAS,
TABLE_GROUP_SCHEMAS,
} from "@src/config/dbPaths";
import { rowyUser } from "@src/utils/table";
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, projectScope);
const [currentUser] = useAtom(currentUserAtom, projectScope);
// Create a function to get the latest tables from project settings,
// so we dont create new functions when tables change
@@ -93,10 +96,11 @@ export function useTableFunctions() {
};
}
const _createdBy = currentUser && rowyUser(currentUser);
// Appends table to settings doc
const promiseUpdateSettings = setDoc(
doc(firebaseDb, SETTINGS),
{ tables: [...tables, settings] },
{ tables: [...tables, { ...settings, _createdBy }] },
{ merge: true }
);
@@ -120,7 +124,7 @@ export function useTableFunctions() {
await Promise.all([promiseUpdateSettings, promiseAddSchema]);
}
);
}, [firebaseDb, readTables, setCreateTable]);
}, [currentUser, firebaseDb, readTables, setCreateTable]);
// Set the createTable function
const setUpdateTable = useSetAtom(updateTableAtom, projectScope);

View File

@@ -14,6 +14,7 @@ import {
_updateRowDbAtom,
_deleteRowDbAtom,
tableNextPageAtom,
serverDocCountAtom
} from "@src/atoms/tableScope";
import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom";
import useFirestoreCollectionWithAtom from "@src/hooks/useFirestoreCollectionWithAtom";
@@ -32,7 +33,11 @@ import { getTableSchemaPath } from "@src/utils/table";
export const TableSourceFirestore = memo(function TableSourceFirestore() {
// Get tableSettings from tableId and tables in projectScope
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const isCollectionGroup = tableSettings?.tableType === "collectionGroup";
if (!tableSettings) throw new Error("No table config");
if (!tableSettings.collection)
throw new Error("Invalid table config: no collection");
const isCollectionGroup = tableSettings.tableType === "collectionGroup";
// Get tableSchema and store in tableSchemaAtom.
// If it doesnt exist, initialize columns
@@ -63,7 +68,7 @@ export const TableSourceFirestore = memo(function TableSourceFirestore() {
useFirestoreCollectionWithAtom(
tableRowsDbAtom,
tableScope,
tableSettings?.collection,
tableSettings.collection,
{
filters,
sorts,
@@ -73,6 +78,7 @@ export const TableSourceFirestore = memo(function TableSourceFirestore() {
updateDocAtom: _updateRowDbAtom,
deleteDocAtom: _deleteRowDbAtom,
nextPageAtom: tableNextPageAtom,
serverDocCountAtom: serverDocCountAtom
}
);

View File

@@ -4,6 +4,7 @@ import type {} from "@mui/lab/themeAugmentation";
import { MultiSelectProps } from "@rowy/multiselect";
import { toRem } from "./typography";
import ModalTransition from "@src/components/Modal/ModalTransition";
import RadioIcon from "@src/theme/RadioIcon";
import CheckboxIcon from "@src/theme/CheckboxIcon";
import CheckboxIndeterminateIcon from "@src/theme/CheckboxIndeterminateIcon";
@@ -248,6 +249,9 @@ export const components = (theme: Theme): ThemeOptions => {
},
MuiDialog: {
defaultProps: {
TransitionComponent: ModalTransition,
},
styleOverrides: {
root: {
"--dialog-title-height": "64px",
@@ -307,14 +311,8 @@ export const components = (theme: Theme): ThemeOptions => {
MuiDialogTitle: {
styleOverrides: {
root: {
padding: "var(--dialog-spacing)",
paddingTop: (64 - 28) / 2,
paddingBottom: (64 - 28) / 2,
[theme.breakpoints.down("sm")]: {
paddingTop: (56 - 28) / 2,
paddingBottom: (56 - 28) / 2,
},
...(theme.typography.h5 as any),
padding: `calc((var(--dialog-title-height) - ${theme.typography.h5.lineHeight} * ${theme.typography.h5.fontSize}) / 2) var(--dialog-spacing)`,
},
},
},
@@ -568,6 +566,13 @@ export const components = (theme: Theme): ThemeOptions => {
MuiMenu: {
styleOverrides: {
root: {
".MuiDialog-root + & .MuiMenu-paper, form:has(.MuiDialog-root) + & .MuiMenu-paper, .MuiDialog-root & .MuiMenu-paper":
{
backgroundImage:
"linear-gradient(rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.2))", // elevation 50
},
},
list: { padding: theme.spacing(0.5, 0) },
},
},
@@ -1370,11 +1375,12 @@ export const components = (theme: Theme): ThemeOptions => {
MuiRating: {
styleOverrides: {
iconFilled: { color: theme.palette.text.secondary },
icon: {
// https://github.com/mui/material-ui/issues/32557
"& .MuiSvgIcon-root": { pointerEvents: "auto" },
color: theme.palette.text.secondary,
},
iconEmpty: { opacity: 0.38 },
},
},

64
src/types/settings.d.ts vendored Normal file
View File

@@ -0,0 +1,64 @@
import { ThemeOptions } from "@mui/material";
import { TableSettings, TableFilter, TableRowRef, TableSort } from "./table";
/** Public settings are visible to unauthenticated users */
export type PublicSettings = Partial<{
signInOptions: Array<
| "google"
| "twitter"
| "facebook"
| "github"
| "microsoft"
| "apple"
| "yahoo"
| "email"
| "phone"
| "anonymous"
>;
theme: Record<"base" | "light" | "dark", ThemeOptions>;
}>;
/** Project settings are visible to authenticated users */
export type ProjectSettings = Partial<{
tables: TableSettings[];
setupCompleted: boolean;
rowyRunUrl: string;
rowyRunRegion: string;
rowyRunDeployStatus: "BUILDING" | "COMPLETE";
services: Partial<{
hooks: string;
builder: string;
terminal: string;
}>;
}>;
/** User info and settings */
export type UserSettings = Partial<{
_rowy_ref: TableRowRef;
/** Synced from user auth info */
user: {
email: string;
displayName?: string;
photoURL?: string;
phoneNumber?: string;
};
roles: string[];
theme: Record<"base" | "light" | "dark", ThemeOptions>;
favoriteTables: string[];
/** Stores user overrides */
tables: Record<
string,
Partial<{
filters: TableFilter[];
hiddenFields: string[];
sorts: TableSort[];
}>
>;
/** Stores table tutorial completion */
tableTutorialComplete?: boolean;
}>;

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

@@ -4,7 +4,10 @@ import type {
DocumentData,
DocumentReference,
} from "firebase/firestore";
import { IExtension } from "@src/components/TableModals/ExtensionsModal/utils";
import {
IExtension,
IRuntimeOptions,
} from "@src/components/TableModals/ExtensionsModal/utils";
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
/**
@@ -70,6 +73,18 @@ export type TableSettings = {
section: string;
description?: string;
details?: string;
thumbnailURL?: string;
_createdBy?: {
displayName?: string;
email?: string;
emailVerified: boolean;
isAnonymous: boolean;
photoURL?: string;
uid: string;
timestamp: firebase.firestore.Timestamp;
};
tableType: "primaryCollection" | "collectionGroup";
@@ -92,6 +107,7 @@ export type TableSchema = {
extensionObjects?: IExtension[];
compiledExtension?: string;
webhooks?: IWebhook[];
runtimeOptions?: IRuntimeOptions;
/** @deprecated Migrate to Extensions */
sparks?: string;
@@ -133,6 +149,10 @@ export type ColumnConfig = {
};
/** FieldType to render for Derivative fields */
renderFieldType?: FieldType;
/** Used in Derivative fields */
listenerFields?: string[];
/** Used in Derivative and Action fields */
requiredFields?: string[];
/** For sub-table fields */
parentLabel?: string[];
@@ -153,7 +173,9 @@ export type TableFilter = {
| "date-before-equal"
| "date-after-equal"
| "time-minute-equal"
| "id-equal";
| "id-equal"
| "color-equal"
| "color-not-equal";
value: any;
};

2321
yarn.lock

File diff suppressed because it is too large Load Diff