Merge branch 'develop' into feature/rowy-706-table-upgrade

This commit is contained in:
Sidney Alcantara
2022-11-14 16:09:01 +11:00
parent 69226d6910
commit e9bc4a5a9b
27 changed files with 789 additions and 247 deletions

View File

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

View File

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

View File

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

View File

@@ -1,134 +1,244 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { format } from "date-fns";
import { find } from "lodash-es";
import { find, isEqual } from "lodash-es";
import MDEditor from "@uiw/react-md-editor";
import { Box, IconButton, Stack, Typography } from "@mui/material";
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, useSetAtom } from "jotai";
import { useAtom } from "jotai";
import {
projectScope,
tablesAtom,
tableSettingsDialogAtom,
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 openTableSettingsDialog = useSetAtom(
tableSettingsDialogAtom,
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 editButton = userRoles.includes("ADMIN") && (
<IconButton
aria-label="Edit"
onClick={() =>
openTableSettingsDialog({
mode: "update",
data: settings,
})
}
disabled={!openTableSettingsDialog || settings.id.includes("/")}
>
<EditIcon />
</IconButton>
);
const handleSave = async () => {
setSaveState("saving");
await updateTable!({
...settings,
description: localDescription,
details: localDetails,
});
setSaveState("saved");
};
const { description, details, _createdBy } = settings;
const isAdmin = userRoles.includes("ADMIN");
return (
<Stack
direction="column"
gap={3}
sx={{
paddingTop: 3,
paddingRight: 3,
paddingBottom: 5,
"& > .MuiGrid-root": {
position: "relative",
},
}}
>
{/* Description */}
<Stack direction="column" gap={1}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-end"
>
<Typography variant="subtitle1" component="h3">
Description
</Typography>
{editButton}
<>
<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>
<Typography variant="body2" color="text.secondary">
{description ? description : "No description"}
</Typography>
</Stack>
{/* Details */}
<Stack direction="column" gap={1}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-end"
>
<Typography variant="subtitle1" component="h3">
Details
</Typography>
{editButton}
</Stack>
{!details ? (
<Typography variant="body2" color="text.secondary">
No details
</Typography>
) : (
{/* 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),
},
}}
>
<MDEditor.Markdown source={details} />
{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>
{/* 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

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

View File

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

View File

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

View File

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

View File

@@ -14,19 +14,40 @@ export default function TableDetails({ ...props }) {
{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"
toolbarHeight={52}
height={150}
value={value}
onChange={onChange}

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

@@ -4,6 +4,15 @@ 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,
@@ -28,7 +37,7 @@ export default function Action({
) : hasRan ? (
value.status
) : (
sanitiseCallableName(column.key)
sanitiseCallableName(getActionName(column))
)}
</div>

View File

@@ -559,20 +559,46 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
title: "Customization",
content: (
<>
<FormControlLabel
control={
<Checkbox
checked={config.customIcons?.enabled}
<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("customIcons.enabled")(e.target.checked)
onChange("customName.actionName")(e.target.value)
}
name="customIcons.enabled"
/>
}
label="Customize button icons with emoji"
style={{ marginLeft: -11 }}
/>
label="Action name:"
className="labelHorizontal"
inputProps={{ style: { width: "10ch" } }}
></TextField>
)}
<FormControlLabel
control={
<Checkbox
checked={config.customIcons?.enabled}
onChange={(e) =>
onChange("customIcons.enabled")(e.target.checked)
}
name="customIcons.enabled"
/>
}
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,
@@ -58,7 +59,7 @@ export default function Action({
) : hasRan ? (
value.status
) : (
sanitiseCallableName(column.key)
sanitiseCallableName(getActionName(column))
)}
</Box>

View File

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

View File

@@ -5,6 +5,7 @@ import { toColor } from "react-color-palette";
import ColorIcon from "@mui/icons-material/Colorize";
import DisplayCell from "./DisplayCell";
import { filterOperators, valueFormatter } from "./filters";
const EditorCell = lazy(
() => import("./EditorCell" /* webpackChunkName: "EditorCell-Color" */)
@@ -28,6 +29,10 @@ export const config: IFieldConfig = {
disablePadding: true,
}),
SideDrawerField,
filter: {
operators: filterOperators,
valueFormatter,
},
csvImportParser: (value: string) => {
try {
const obj = JSON.parse(value);

View File

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

View File

@@ -2,24 +2,7 @@ import React, { forwardRef } from "react";
import { IDisplayCellProps } from "@src/components/fields/types";
import MuiRating, { RatingProps as MuiRatingProps } from "@mui/material/Rating";
import RatingIcon from "@mui/icons-material/Star";
import RatingOutlineIcon from "@mui/icons-material/StarBorder";
import { get } from "lodash-es";
export const getStateIcon = (config: any) => {
// only use the config to get the custom rating icon if enabled via toggle
if (!get(config, "customIcons.enabled")) {
return <RatingIcon />;
}
return get(config, "customIcons.rating") || <RatingIcon />;
};
export const getStateOutline = (config: any) => {
if (!get(config, "customIcons.enabled")) {
return <RatingOutlineIcon />;
}
return get(config, "customIcons.rating") || <RatingOutlineIcon />;
};
import Icon from "./Icon";
export const Rating = forwardRef(function Rating(
{
@@ -55,14 +38,15 @@ export const Rating = forwardRef(function Rating(
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key))
e.stopPropagation();
}}
icon={getStateIcon(column.config)}
icon={<Icon config={column.config} isEmpty={false} />}
emptyIcon={<Icon config={column.config} isEmpty={true} />}
size="small"
readOnly={disabled}
emptyIcon={getStateOutline(column.config)}
max={max}
precision={precision}
sx={{ mx: -0.25 }}
/>
);
});
export default Rating;

View File

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

View File

@@ -1,11 +1,17 @@
import { ISettingsProps } from "@src/components/fields/types";
import RatingIcon from "@mui/icons-material/Star";
import RatingOutlineIcon from "@mui/icons-material/StarBorder"
import { InputLabel, TextField, Grid, FormControlLabel, Checkbox, Stack } from "@mui/material";
import {
InputLabel,
TextField,
Grid,
FormControlLabel,
Checkbox,
Stack,
} from "@mui/material";
import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import MuiRating from "@mui/material/Rating";
import { get } from "lodash-es";
import Icon from "./Icon";
export default function Settings({ onChange, config }: ISettingsProps) {
return (
@@ -18,10 +24,13 @@ export default function Settings({ onChange, config }: ISettingsProps) {
fullWidth
error={false}
onChange={(e) => {
let input = parseInt(e.target.value) || 0
if (input > 20) { input = 20 }
let input = parseInt(e.target.value) || 0;
if (input > 20) {
input = 20;
}
onChange("max")(input);
}}
inputProps={{ min: 1, max: 20 }}
/>
</Grid>
<Grid item xs={6}>
@@ -68,28 +77,26 @@ export default function Settings({ onChange, config }: ISettingsProps) {
<TextField
id="customIcons.rating"
value={get(config, "customIcons.rating")}
onChange={(e) =>
onChange("customIcons.rating")(e.target.value)
}
onChange={(e) => onChange("customIcons.rating")(e.target.value)}
label="Custom icon preview:"
className="labelHorizontal"
inputProps={{ style: { width: "2ch" } }}
/>
<MuiRating aria-label="Preview of the rating field with custom icon"
<MuiRating
aria-label="Preview of the rating field with custom icon"
name="Preview"
onClick={(e) => e.stopPropagation()}
icon={get(config, "customIcons.rating") || <RatingIcon />}
icon={<Icon config={config} isEmpty={false} />}
size="small"
emptyIcon={get(config, "customIcons.rating") || <RatingOutlineIcon />}
emptyIcon={<Icon config={config} isEmpty={true} />}
max={get(config, "max")}
precision={get(config, "precision")}
sx={{ pt: 0.5 }}
/>
</Stack>
</Grid>
)}
</Grid>
);
}
}

View File

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

View File

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

@@ -376,12 +376,20 @@ export const tableFiltersToFirestoreFilters = (filters: TableFilter[]) => {
} else if (filter.operator === "id-equal") {
firestoreFilters.push(where(documentId(), "==", filter.value));
continue;
} else if (filter.operator === "color-equal") {
firestoreFilters.push(
where(filter.key.concat(".hex"), "==", filter.value.hex.toString())
);
continue;
} else if (filter.operator === "color-not-equal") {
firestoreFilters.push(
where(filter.key.concat(".hex"), "!=", filter.value.hex.toString())
);
continue;
}
firestoreFilters.push(
where(filter.key, filter.operator as WhereFilterOp, filter.value)
);
}
return firestoreFilters;
};

View File

@@ -8,11 +8,11 @@ import reportWebVitals from "./reportWebVitals";
const container = document.getElementById("root")!;
const root = createRoot(container);
root.render(
<StrictMode>
<Providers>
<App />
</Providers>
</StrictMode>
// <StrictMode>
<Providers>
<App />
</Providers>
// </StrictMode>
);
// If you want to start measuring performance in your app, pass a function

View File

@@ -1,16 +1,20 @@
import { lazy, Suspense } from "react";
import { useAtom, Provider } from "jotai";
import { DebugAtoms } from "@src/atoms/utils";
import { useParams, useOutlet } from "react-router-dom";
import { useParams, useOutlet, Link } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import { find, isEmpty } from "lodash-es";
import useOffline from "@src/hooks/useOffline";
import ErrorFallback, {
ERROR_TABLE_NOT_FOUND,
} from "@src/components/ErrorFallback";
import { Typography, Button } from "@mui/material";
import ErrorFallback from "@src/components/ErrorFallback";
import TableSourceFirestore from "@src/sources/TableSourceFirestore";
import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton";
import TableSkeleton from "@src/components/Table/TableSkeleton";
import EmptyState from "@src/components/EmptyState";
import OfflineIcon from "@mui/icons-material/CloudOff";
import { Tables as TablesIcon } from "@src/assets/icons";
import {
projectScope,
@@ -25,6 +29,7 @@ import {
tableSettingsAtom,
} from "@src/atoms/tableScope";
import { SyncAtomValue } from "@src/atoms/utils";
import { ROUTES } from "@src/constants/routes";
import useDocumentTitle from "@src/hooks/useDocumentTitle";
// prettier-ignore
@@ -41,6 +46,7 @@ export default function ProvidedTablePage() {
const [currentUser] = useAtom(currentUserAtom, projectScope);
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
const [tables] = useAtom(tablesAtom, projectScope);
const isOffline = useOffline();
const tableSettings = find(tables, ["id", id]);
useDocumentTitle(projectId, tableSettings ? tableSettings.name : "Not found");
@@ -54,7 +60,41 @@ export default function ProvidedTablePage() {
</>
);
} else {
throw new Error(ERROR_TABLE_NOT_FOUND + ": " + id);
if (isOffline) {
return (
<EmptyState
role="alert"
fullScreen
Icon={OfflineIcon}
message="Youre offline"
/>
);
} else {
return (
<EmptyState
role="alert"
fullScreen
message="Table not found"
description={
<>
<Typography variant="inherit">
Make sure you have the right ID
</Typography>
<code>{id}</code>
<Button
variant="outlined"
color="secondary"
component={Link}
to={ROUTES.tables}
startIcon={<TablesIcon />}
>
All tables
</Button>
</>
}
/>
);
}
}
}

View File

@@ -1381,11 +1381,12 @@ export const components = (theme: Theme): ThemeOptions => {
MuiRating: {
styleOverrides: {
iconFilled: { color: theme.palette.text.secondary },
icon: {
// https://github.com/mui/material-ui/issues/32557
"& .MuiSvgIcon-root": { pointerEvents: "auto" },
color: theme.palette.text.secondary,
},
iconEmpty: { opacity: 0.38 },
},
},

16
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";
/**
@@ -104,6 +107,7 @@ export type TableSchema = {
extensionObjects?: IExtension[];
compiledExtension?: string;
webhooks?: IWebhook[];
runtimeOptions?: IRuntimeOptions;
/** @deprecated Migrate to Extensions */
sparks?: string;
@@ -146,7 +150,11 @@ export type ColumnConfig = {
/** Regex used in CellValidation */
validationRegex: string;
/** FieldType to render for Derivative fields */
renderFieldType: FieldType;
renderFieldType?: FieldType;
/** Used in Derivative fields */
listenerFields?: string[];
/** Used in Derivative and Action fields */
requiredFields?: string[];
/** For sub-table fields */
parentLabel: string[];
@@ -167,7 +175,9 @@ export type TableFilter = {
| "date-before-equal"
| "date-after-equal"
| "time-minute-equal"
| "id-equal";
| "id-equal"
| "color-equal"
| "color-not-equal";
value: any;
};