mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
Merge branch 'develop' into multi-file-upload
This commit is contained in:
@@ -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, // Don’t require this to be set explicitly
|
||||
...update,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
244
src/components/TableInformationDrawer/Details.tsx
Normal file
244
src/components/TableInformationDrawer/Details.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
src/components/TableInformationDrawer/TableInformationDrawer.tsx
Normal file
134
src/components/TableInformationDrawer/TableInformationDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/TableInformationDrawer/index.ts
Normal file
2
src/components/TableInformationDrawer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./TableInformationDrawer";
|
||||
export { default } from "./TableInformationDrawer";
|
||||
@@ -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={{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
117
src/components/TableModals/ExtensionsModal/RuntimeOptions.tsx
Normal file
117
src/components/TableModals/ExtensionsModal/RuntimeOptions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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" }} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
{}
|
||||
</>
|
||||
|
||||
@@ -78,6 +78,12 @@ export interface IWebhook {
|
||||
auth?: any;
|
||||
}
|
||||
|
||||
export interface ISecret {
|
||||
loading: boolean;
|
||||
keys: string[];
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const webhookSchemas = {
|
||||
basic,
|
||||
typeform,
|
||||
|
||||
64
src/components/TableSettingsDialog/TableDetails.tsx
Normal file
64
src/components/TableSettingsDialog/TableDetails.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
126
src/components/TableSettingsDialog/TableThumbnail.tsx
Normal file
126
src/components/TableSettingsDialog/TableThumbnail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
14
src/components/TableSettingsDialog/utils.ts
Normal file
14
src/components/TableSettingsDialog/utils.ts
Normal 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)
|
||||
);
|
||||
};
|
||||
25
src/components/TableToolbar/TableInformation.tsx
Normal file
25
src/components/TableToolbar/TableInformation.tsx
Normal 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("/")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 it’s 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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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`,
|
||||
|
||||
|
||||
@@ -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 doesn’t fire on initial load
|
||||
setIsOffline(!window.navigator.onLine);
|
||||
|
||||
window.addEventListener("offline", handleOffline);
|
||||
window.addEventListener("online", handleOnline);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 don’t 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);
|
||||
|
||||
@@ -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 doesn’t 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
18
src/types/table.d.ts
vendored
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user