Merge branch 'develop' into multi-file-upload

This commit is contained in:
shamsmosowi
2022-10-28 19:10:30 +02:00
46 changed files with 1257 additions and 186 deletions

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

@@ -75,7 +75,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 +93,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 });

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

@@ -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

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useAtom } from "jotai";
import {
@@ -10,6 +10,7 @@ import {
TextField,
Button,
} from "@mui/material";
import MemoizedText from "@src/components/Modal/MemoizedText";
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
@@ -48,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
@@ -63,62 +69,72 @@ export default function ConfirmDialog({
maxWidth={maxWidth}
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

@@ -13,7 +13,7 @@ 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";
import { EXTERNAL_LINKS } from "@src/constants/externalLinks";
export const ERROR_TABLE_NOT_FOUND = "Table not found";
@@ -37,14 +37,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 how to reproduce this bug here.**",
}).toString()
}
target="_blank"
rel="noopener noreferrer"

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

@@ -27,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>

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

@@ -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

@@ -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,24 @@ 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: !(
!!localRuntimeOptions.timeoutSeconds &&
localRuntimeOptions.timeoutSeconds > 0 &&
localRuntimeOptions.timeoutSeconds <= 540
),
},
};
const [openMigrationGuide, setOpenMigrationGuide] = useState(false);
useEffect(() => {
if (tableSchema.sparks) setOpenMigrationGuide(true);
@@ -57,7 +76,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 +91,8 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
cancel: "Keep",
handleConfirm: () => {
_setOpen(false);
setLocalExtensionsObjects(currentExtensionObjects);
setLocalExtensionsObjects(tableSchema.extensionObjects ?? []);
setLocalRuntimeOptions(tableSchema.runtimeOptions ?? {});
onClose();
},
});
@@ -79,15 +101,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 +157,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 +249,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

@@ -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

@@ -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

@@ -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}>

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

@@ -1,11 +1,14 @@
import { useState, useEffect } from "react";
export default function useOffline() {
const [isOffline, setIsOffline] = useState(true);
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);

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

@@ -41,7 +41,7 @@ export default function DebugPage() {
{userRoles.includes("ADMIN") && <UserManagementSourceFirebase />}
<Stack spacing={4}>
<SettingsSection title="Firestore config">
<SettingsSection title="Firestore config" transitionTimeout={0 * 100}>
<Button
href={`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${CONFIG.replace(
/\//g,
@@ -83,7 +83,10 @@ export default function DebugPage() {
</SettingsSection>
{userRoles.includes("ADMIN") && (
<SettingsSection title="Reset table filters">
<SettingsSection
title="Reset table filters"
transitionTimeout={1 * 100}
>
<Button
onClick={async () => {
if (!updateUser)
@@ -167,7 +170,7 @@ export default function DebugPage() {
<SettingsSection
title="Local Firestore instance"
transitionTimeout={1 * 100}
transitionTimeout={2 * 100}
>
<Button
onClick={async () => {

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

@@ -32,7 +32,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 +67,7 @@ export const TableSourceFirestore = memo(function TableSourceFirestore() {
useFirestoreCollectionWithAtom(
tableRowsDbAtom,
tableScope,
tableSettings?.collection,
tableSettings.collection,
{
filters,
sorts,

18
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;