Merge branch 'develop' into dependabot/npm_and_yarn/follow-redirects-1.14.7

This commit is contained in:
Sidney Alcantara
2022-02-02 16:50:23 +11:00
committed by GitHub
112 changed files with 5334 additions and 2121 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "Rowy",
"version": "2.2.0",
"version": "2.3.0-rc.0",
"homepage": "https://rowy.io",
"repository": {
"type": "git",
@@ -12,14 +12,14 @@
"@date-io/date-fns": "1.x",
"@emotion/react": "^11.4.0",
"@emotion/styled": "^11.3.0",
"@hookform/resolvers": "^2.8.1",
"@hookform/resolvers": "^2.8.5",
"@mdi/js": "^6.5.95",
"@monaco-editor/react": "^4.3.1",
"@mui/icons-material": "^5.2.0",
"@mui/lab": "^5.0.0-alpha.58",
"@mui/material": "^5.2.2",
"@mui/styles": "^5.2.2",
"@rowy/form-builder": "^0.4.2",
"@rowy/form-builder": "^0.5.2",
"@rowy/multiselect": "^0.2.3",
"@tinymce/tinymce-react": "^3.12.6",
"algoliasearch": "^4.8.6",
@@ -33,7 +33,7 @@
"file-saver": "^2.0.5",
"firebase": "8.6.8",
"hotkeys-js": "^3.7.2",
"jotai": "^1.4.2",
"jotai": "^1.5.3",
"json-stable-stringify-without-jsonify": "^1.0.1",
"json2csv": "^5.0.6",
"jszip": "^3.6.0",
@@ -54,17 +54,19 @@
"react-element-scroll-hook": "^1.1.0",
"react-firebaseui": "^5.0.2",
"react-helmet": "^6.1.0",
"react-hook-form": "^7.16.1",
"react-hook-form": "^7.21.2",
"react-image": "^4.0.3",
"react-joyride": "^2.3.0",
"react-json-view": "^1.19.1",
"react-markdown": "^8.0.0",
"react-router-dom": "^5.0.1",
"react-router-hash-link": "^2.4.3",
"react-scripts": "^4.0.3",
"react-usestateref": "^1.0.5",
"remark-gfm": "^3.0.1",
"serve": "^11.3.2",
"swr": "^1.0.1",
"tinymce": "^5.9.2",
"tinymce": "^5.10.0",
"typescript": "^4.4.2",
"use-algolia": "^1.4.1",
"use-debounce": "^3.3.0",
@@ -115,7 +117,7 @@
"@types/react-router-hash-link": "^2.4.1",
"@types/use-persisted-state": "^0.3.0",
"craco-alias": "^3.0.1",
"firebase-tools": "^8.12.1",
"firebase-tools": "^10.1.0",
"husky": "^4.2.5",
"monaco-editor": "^0.21.2",
"playwright": "^1.5.2",
@@ -123,6 +125,9 @@
"pretty-quick": "^3.0.0",
"raw-loader": "^4.0.2"
},
"resolutions": {
"react-hook-form": "^7.21.2"
},
"husky": {
"hooks": {
"pre-commit": "pretty-quick --staged"

Binary file not shown.

5
src/atoms/Table.ts Normal file
View File

@@ -0,0 +1,5 @@
import { atomWithHash } from "jotai/utils";
export const modalAtom = atomWithHash<
"cloudLogs" | "extensions" | "webhooks" | "export" | ""
>("modal", "");

View File

@@ -12,7 +12,7 @@ type ExtensionContext = {
ref: FirebaseFirestore.DocumentReference;
storage: firebasestorage.Storage;
db: FirebaseFirestore.Firestore;
auth: adminauth.BaseAuth;
auth: firebaseauth.BaseAuth;
change: any;
triggerType: Triggers;
fieldTypes: any;

View File

@@ -2,7 +2,7 @@
/* eslint-disable @typescript-eslint/ban-types */
export namespace admin.auth {
declare namespace firebaseauth {
/**
* Interface representing a user's metadata.
*/

32
src/components/CodeEditor/rowy.d.ts vendored Normal file
View File

@@ -0,0 +1,32 @@
/**
* utility functions
*/
declare namespace rowy {
/**
* uploads a file to the cloud storage from a url
*/
function url2storage(
url: string,
storagePath: string,
fileName?: string
): Promise<{
downloadURL: string;
name: string;
type: string;
lastModifiedTS: Date;
}>;
/**
* Gets the secret defined in Google Cloud Secret
*/
async function getSecret(name: string, v?: string): Promise<string | null>;
async function getServiceAccountUser(): Promise<{
email: string;
emailVerified: boolean;
displayName: string;
photoURL: string;
uid: string;
timestamp: number;
}>;
}

View File

@@ -16,6 +16,7 @@ import firestoreDefs from "!!raw-loader!./firestore.d.ts";
import firebaseAuthDefs from "!!raw-loader!./firebaseAuth.d.ts";
import firebaseStorageDefs from "!!raw-loader!./firebaseStorage.d.ts";
import utilsDefs from "!!raw-loader!./utils.d.ts";
import rowyUtilsDefs from "!!raw-loader!./rowy.d.ts";
import extensionsDefs from "!!raw-loader!./extensions.d.ts";
export interface IUseMonacoCustomizationsProps {
@@ -96,6 +97,7 @@ export default function useMonacoCustomizations({
utilsDefs,
"ts:filename/utils.d.ts"
);
monaco.languages.typescript.javascriptDefaults.addExtraLib(rowyUtilsDefs);
} catch (error) {
console.error(
"An error occurred during initialization of Monaco: ",
@@ -174,7 +176,7 @@ export default function useMonacoCustomizations({
"const ref: FirebaseFirestore.DocumentReference;",
"const storage: firebasestorage.Storage;",
"const db: FirebaseFirestore.Firestore;",
"const auth: adminauth.BaseAuth;",
"const auth: firebaseauth.BaseAuth;",
"declare class row {",
" /**",
" * Returns the row fields",

View File

@@ -31,7 +31,10 @@ export default function Confirmation({
return (
<Dialog
open={open}
onClose={handleClose}
onClose={(_, reason) => {
if (reason === "backdropClick" || reason === "escapeKeyDown") return;
else handleClose();
}}
maxWidth={maxWidth}
TransitionComponent={SlideTransitionMui}
style={{ cursor: "default" }}

View File

@@ -10,6 +10,7 @@ import {
} from "@mui/material";
import GoIcon from "@src/assets/icons/Go";
import RenderedMarkdown from "@src/components/RenderedMarkdown";
import { Table } from "@src/contexts/ProjectContext";
export interface ITableCardProps extends Table {
@@ -26,36 +27,38 @@ export default function TableCard({
}: ITableCardProps) {
return (
<Card style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<CardActionArea
sx={{
flexGrow: 1,
display: "flex",
alignItems: "flex-start",
justifyContent: "flex-start",
borderRadius: 2,
}}
component={Link}
to={link}
>
<CardContent>
<CardActionArea component={Link} to={link}>
<CardContent style={{ paddingBottom: 0 }}>
<Typography variant="overline" component="p">
{section}
</Typography>
<Typography variant="h6" component="h3" gutterBottom>
{name}
</Typography>
<Typography
color="textSecondary"
sx={{
minHeight: (theme) =>
(theme.typography.body2.lineHeight as number) * 2 + "em",
}}
>
{description}
</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"
/>
)}
</Typography>
</CardContent>
<CardActions>
<Button
variant="text"

View File

@@ -8,6 +8,7 @@ import {
} from "@mui/material";
import GoIcon from "@mui/icons-material/ArrowForward";
import RenderedMarkdown from "@src/components/RenderedMarkdown";
import { Table } from "@src/contexts/ProjectContext";
export interface ITableListItemProps extends Table {
@@ -36,30 +37,32 @@ export default function TableListItem({
"& > *": { lineHeight: "48px !important" },
flexWrap: "nowrap",
overflow: "hidden",
flexBasis: 160 + 16,
flexGrow: 0,
flexShrink: 0,
mr: 2,
}}
>
{/* <Typography
variant="overline"
component="p"
noWrap
color="textSecondary"
sx={{ maxWidth: 100, flexShrink: 0, flexGrow: 1, mr: 2 }}
>
{section}
</Typography> */}
<Typography
component="h3"
variant="button"
noWrap
sx={{ maxWidth: 160, flexShrink: 0, flexGrow: 1, mr: 2 }}
>
<Typography component="h3" variant="button" noWrap>
{name}
</Typography>
<Typography color="textSecondary" noWrap>
{description}
</Typography>
</ListItemButton>
<Typography
color="textSecondary"
component="div"
noWrap
sx={{ flexGrow: 1, "& *": { display: "inline" } }}
>
{description && (
<RenderedMarkdown
children={description}
restrictionPreset="singleLine"
/>
)}
</Typography>
<div style={{ flexShrink: 0 }}>
{actions}

View File

@@ -0,0 +1,107 @@
import { useState } from "react";
import _merge from "lodash/merge";
import { Tooltip, IconButton } from "@mui/material";
import { alpha } from "@mui/material/styles";
import InfoIcon from "@mui/icons-material/InfoOutlined";
import CloseIcon from "@mui/icons-material/Close";
export interface IInfoTooltipProps {
description: React.ReactNode;
buttonLabel?: string;
defaultOpen?: boolean;
onClose?: () => void;
buttonProps?: Partial<React.ComponentProps<typeof IconButton>>;
tooltipProps?: Partial<React.ComponentProps<typeof Tooltip>>;
iconProps?: Partial<React.ComponentProps<typeof InfoIcon>>;
}
export default function InfoTooltip({
description,
buttonLabel = "Info",
defaultOpen,
onClose,
buttonProps,
tooltipProps,
iconProps,
}: IInfoTooltipProps) {
const [open, setOpen] = useState(defaultOpen || false);
const handleClose = () => {
setOpen(false);
if (onClose) onClose();
};
const toggleOpen = () => {
if (open) {
setOpen(false);
if (onClose) onClose();
} else {
setOpen(true);
}
};
return (
<Tooltip
title={
<>
{description}
<IconButton
aria-label={`Close ${buttonLabel}`}
size="small"
onClick={handleClose}
sx={{
m: -0.5,
opacity: 0.8,
"&:hover": {
backgroundColor: (theme) =>
alpha("#fff", theme.palette.action.hoverOpacity),
},
}}
color="inherit"
>
<CloseIcon fontSize="small" />
</IconButton>
</>
}
disableFocusListener
disableHoverListener
disableTouchListener
arrow
placement="right-start"
describeChild
{...tooltipProps}
open={open}
componentsProps={_merge(
{
tooltip: {
style: {
marginLeft: "8px",
transformOrigin: "-8px 14px",
},
sx: {
typography: "body2",
display: "flex",
gap: 1.5,
alignItems: "flex-start",
pr: 0.5,
},
},
},
tooltipProps?.componentsProps
)}
>
<IconButton
aria-label={buttonLabel}
size="small"
{...buttonProps}
onClick={toggleOpen}
>
{buttonProps?.children || <InfoIcon fontSize="small" {...iconProps} />}
</IconButton>
</Tooltip>
);
}

View File

@@ -1,22 +1,36 @@
import { useState } from "react";
import _find from "lodash/find";
import queryString from "query-string";
import { Link as RouterLink } from "react-router-dom";
import _camelCase from "lodash/camelCase";
import { useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import {
Breadcrumbs as MuiBreadcrumbs,
BreadcrumbsProps,
Link,
Typography,
Tooltip,
} from "@mui/material";
import ArrowRightIcon from "@mui/icons-material/ChevronRight";
import ReadOnlyIcon from "@mui/icons-material/EditOffOutlined";
import InfoTooltip from "@src/components/InfoTooltip";
import RenderedMarkdown from "@src/components/RenderedMarkdown";
import { useAppContext } from "@src/contexts/AppContext";
import { useProjectContext } from "@src/contexts/ProjectContext";
import useRouter from "@src/hooks/useRouter";
import routes from "@src/constants/routes";
export default function Breadcrumbs(props: BreadcrumbsProps) {
const { tables, tableState } = useProjectContext();
const tableDescriptionDismissedAtom = atomWithStorage<string[]>(
"tableDescriptionDismissed",
[]
);
export default function Breadcrumbs({ sx = [], ...props }: BreadcrumbsProps) {
const { userClaims } = useAppContext();
const { tables, table, tableState } = useProjectContext();
const id = tableState?.config.id || "";
const collection = id || tableState?.tablePath || "";
@@ -28,22 +42,26 @@ export default function Breadcrumbs(props: BreadcrumbsProps) {
const breadcrumbs = collection.split("/");
const section = _find(tables, ["id", breadcrumbs[0]])?.section || "";
const section = table?.section || "";
const getLabel = (id: string) => _find(tables, ["id", id])?.name || id;
const [dismissed, setDismissed] = useAtom(tableDescriptionDismissedAtom);
return (
<MuiBreadcrumbs
separator={<ArrowRightIcon />}
aria-label="Sub-table breadcrumbs"
sx={{
"& ol": {
pl: 2,
userSelect: "none",
flexWrap: "nowrap",
whiteSpace: "nowrap",
{...props}
sx={[
{
"& .MuiBreadcrumbs-ol": {
userSelect: "none",
flexWrap: "nowrap",
whiteSpace: "nowrap",
},
},
}}
{...(props as any)}
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Section name */}
{section && (
@@ -63,7 +81,7 @@ export default function Breadcrumbs(props: BreadcrumbsProps) {
const crumbProps = {
key: index,
variant: "h6",
component: index === 0 ? "h2" : "div",
component: index === 0 ? "h1" : "div",
color:
index === breadcrumbs.length - 1 ? "textPrimary" : "textSecondary",
} as const;
@@ -71,9 +89,50 @@ export default function Breadcrumbs(props: BreadcrumbsProps) {
// If its the last crumb, just show the label without linking
if (index === breadcrumbs.length - 1)
return (
<Typography {...crumbProps}>
{getLabel(crumb) || crumb.replace(/([A-Z])/g, " $1")}
</Typography>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Typography {...crumbProps}>
{getLabel(crumb) || crumb.replace(/([A-Z])/g, " $1")}
</Typography>
{crumb === table?.id && table?.readOnly && (
<Tooltip
title={
userClaims?.roles.includes("ADMIN")
? "Table is read-only for non-ADMIN users"
: "Table is read-only"
}
>
<ReadOnlyIcon fontSize="small" sx={{ ml: 0.5 }} />
</Tooltip>
)}
{crumb === table?.id && table?.description && (
<InfoTooltip
description={
<div>
<RenderedMarkdown
children={table?.description}
restrictionPreset="singleLine"
/>
</div>
}
buttonLabel="Table info"
tooltipProps={{
componentsProps: {
popper: { sx: { zIndex: "appBar" } },
tooltip: { sx: { maxWidth: "75vw" } },
} as any,
}}
defaultOpen={!dismissed.includes(table?.id)}
onClose={() => setDismissed((d) => [...d, table?.id])}
/>
)}
</div>
);
// If odd: breadcrumb points to a document — link to rowRef

View File

@@ -0,0 +1,37 @@
import ReactMarkdown from "react-markdown";
import type { ReactMarkdownOptions } from "react-markdown/lib/react-markdown";
import remarkGfm from "remark-gfm";
import { Typography, Link } from "@mui/material";
const remarkPlugins = [remarkGfm];
const components = {
a: (props) => <Link color="inherit" {...props} />,
p: Typography,
// eslint-disable-next-line jsx-a11y/alt-text
img: (props) => <img style={{ maxWidth: "100%" }} {...props} />,
};
const restrictionPresets = {
singleLine: ["p", "em", "strong", "a", "code", "del"],
};
export interface IRenderedMarkdownProps extends ReactMarkdownOptions {
restrictionPreset?: keyof typeof restrictionPresets;
}
export default function RenderedMarkdown({
restrictionPreset,
...props
}: IRenderedMarkdownProps) {
return (
<ReactMarkdown
{...props}
allowedElements={restrictionPresets[restrictionPreset || ""]}
unwrapDisallowed
linkTarget="_blank"
remarkPlugins={remarkPlugins}
components={components}
/>
);
}

View File

@@ -119,7 +119,7 @@ export default function Step1RowyRun({
<TextField
id="rowyRunUrl"
label="Rowy Run instance URL"
placeholder="https://*.run.app"
placeholder="https://rowy-backend-*.run.app"
value={rowyRunUrl}
onChange={(e) => setRowyRunUrl(e.target.value)}
type="url"

View File

@@ -13,7 +13,7 @@ import { rowyRun } from "@src/utils/rowyRun";
import { runRoutes } from "@src/constants/runRoutes";
import CopyIcon from "@src/assets/icons/Copy";
export default function Step3ProjectOwner({
export default function Step2ProjectOwner({
rowyRunUrl,
completion,
setCompletion,
@@ -33,7 +33,7 @@ export default function Step3ProjectOwner({
const [isDomainAuthorized, setIsDomainAuthorized] = useState(
!!currentUser || completion.projectOwner
);
const isSignedIn = currentUser?.email === email;
const isSignedIn = currentUser?.email?.toLowerCase() === email.toLowerCase();
const [hasRoles, setHasRoles] = useState<boolean | "LOADING" | string>(
completion.projectOwner
);

View File

@@ -1,174 +0,0 @@
import { useState, useEffect } from "react";
import { ISetupStepBodyProps } from "@src/pages/Setup";
import { Typography, Link, Stack } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import SetupItem from "./SetupItem";
import { name } from "@root/package.json";
import { useAppContext } from "@src/contexts/AppContext";
import { rowyRun } from "@src/utils/rowyRun";
import { runRoutes } from "@src/constants/runRoutes";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import screenRecording from "@src/assets/service-account.mp4";
export default function Step2ServiceAccount({
rowyRunUrl,
completion,
setCompletion,
}: ISetupStepBodyProps) {
const [hasAllRoles, setHasAllRoles] = useState(completion.serviceAccount);
// const [roles, setRoles] = useState<Record<string, any>>({});
const [verificationStatus, setVerificationStatus] = useState<
"IDLE" | "LOADING" | "FAIL"
>("IDLE");
const { projectId } = useAppContext();
const [region, setRegion] = useState("");
useEffect(() => {
fetch(rowyRunUrl + runRoutes.region.path, {
method: runRoutes.region.method,
})
.then((res) => res.json())
.then((data) => setRegion(data.region))
.catch((e) => console.error(e));
}, []);
const verifyRoles = async () => {
setVerificationStatus("LOADING");
try {
const result = await checkServiceAccount(rowyRunUrl);
// setRoles(result);
if (result.hasAllRoles) {
setVerificationStatus("IDLE");
setHasAllRoles(true);
setCompletion((c) => ({ ...c, serviceAccount: true }));
} else {
setVerificationStatus("FAIL");
setHasAllRoles(false);
}
} catch (e) {
console.error(e);
setVerificationStatus("FAIL");
}
};
return (
<>
<Typography variant="inherit">
{name} Run uses a{" "}
<Link
href="https://firebase.google.com/support/guides/service-accounts"
target="_blank"
rel="noopener noreferrer"
color="text.primary"
>
service account
</Link>{" "}
to access your project. It operates exclusively on your GCP project, so
we never have access to any of your data.{" "}
<Link
href={WIKI_LINKS.rowyRun}
target="_blank"
rel="noopener noreferrer"
color="text.secondary"
>
Learn more
<InlineOpenInNewIcon />
</Link>
</Typography>
<SetupItem
status={hasAllRoles ? "complete" : "incomplete"}
title={
hasAllRoles
? "Rowy Run has access to a service account with all the required roles."
: "Set up a service account with the following roles:"
}
>
{!hasAllRoles && (
<>
<ul>
<li>Service Account User</li>
<li>Firebase Admin</li>
</ul>
<Stack direction="row" spacing={1}>
<LoadingButton
// loading={!region}
href={`https://console.cloud.google.com/run/deploy/${region}/rowy-run?project=${projectId}`}
target="_blank"
rel="noopener noreferrer"
>
Set up service account
<InlineOpenInNewIcon />
</LoadingButton>
<LoadingButton
variant="contained"
color="primary"
onClick={verifyRoles}
loading={verificationStatus === "LOADING"}
>
Verify
</LoadingButton>
</Stack>
{verificationStatus === "FAIL" && (
<Typography variant="inherit" color="error">
Some roles are missing. Also make sure your Firebase project has
Firestore and Authentication enabled.{" "}
<Link
href={WIKI_LINKS.setupFirebaseProject}
target="_blank"
rel="noopener noreferrer"
color="text.primary"
>
Setup guide
<InlineOpenInNewIcon />
</Link>
</Typography>
)}
<Typography variant="inherit">
Follow the steps in the screen recording below:
</Typography>
<video
src={screenRecording}
controls
muted
playsInline
style={{ width: "100%" }}
/>
</>
)}
</SetupItem>
</>
);
}
export const checkServiceAccount = async (
serviceUrl: string,
signal?: AbortSignal
) => {
try {
const res = await rowyRun({
serviceUrl,
route: runRoutes.serviceAccountAccess,
signal,
});
return {
...res,
hasAllRoles: Object.values(res).reduce(
(acc, value) => acc && value,
true
) as boolean,
};
} catch (e: any) {
console.error(e);
return false;
}
};

View File

@@ -16,6 +16,7 @@ import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import SetupItem from "./SetupItem";
import DiffEditor from "@src/components/CodeEditor/DiffEditor";
import CodeEditor from "@src/components/CodeEditor";
import { name } from "@root/package.json";
import { useAppContext } from "@src/contexts/AppContext";
@@ -30,7 +31,17 @@ import { rowyRun } from "@src/utils/rowyRun";
import { runRoutes } from "@src/constants/runRoutes";
// import { useConfirmation } from "@src/components/ConfirmationDialog";
export default function Step4Rules({
const insecureRuleRegExp = new RegExp(
insecureRule
.replace(/\//g, "\\/")
.replace(/\*/g, "\\*")
.replace(/\s{2,}/g, "\\s+")
.replace(/\s/g, "\\s*")
.replace(/\n/g, "\\s+")
.replace(/;/g, ";?")
);
export default function Step3Rules({
rowyRunUrl,
completion,
setCompletion,
@@ -38,12 +49,18 @@ export default function Step4Rules({
const { projectId, getAuthToken } = useAppContext();
// const { requestConfirmation } = useConfirmation();
const [error, setError] = useState<string | false>(false);
const [hasRules, setHasRules] = useState(completion.rules);
const [adminRule, setAdminRule] = useState(true);
const [showManualMode, setShowManualMode] = useState(false);
const rules = `${
adminRule ? adminRules : ""
}${requiredRules}${utilFns}`.replace("\n", "");
error === "security-rules/not-found"
? `rules_version = '2';\n\nservice cloud.firestore {\n match /databases/{database}/documents {\n`
: ""
}${adminRule ? adminRules : ""}${requiredRules}${utilFns}${
error === "security-rules/not-found" ? " }\n}" : ""
}`.replace("\n", "");
const [currentRules, setCurrentRules] = useState("");
useEffect(() => {
@@ -56,18 +73,16 @@ export default function Step4Rules({
authToken,
})
)
.then((data) => setCurrentRules(data?.source?.[0]?.content ?? ""));
.then((data) => {
if (data?.code) {
setError(data.code);
setShowManualMode(true);
} else {
setCurrentRules(data?.source?.[0]?.content ?? "");
}
});
}, [rowyRunUrl, hasRules, currentRules, getAuthToken]);
const insecureRuleRegExp = new RegExp(
insecureRule
.replace(/\//g, "\\/")
.replace(/\*/g, "\\*")
.replace(/\s{2,}/g, "\\s+")
.replace(/\s/g, "\\s*")
.replace(/\n/g, "\\s+")
.replace(/;/g, ";?")
);
const hasInsecureRule = insecureRuleRegExp.test(currentRules);
const [newRules, setNewRules] = useState("");
@@ -89,7 +104,7 @@ export default function Step4Rules({
if (hasInsecureRule) inserted = inserted.replace(insecureRuleRegExp, "");
setNewRules(inserted);
}, [currentRules, rules, hasInsecureRule, insecureRuleRegExp]);
}, [currentRules, rules, hasInsecureRule]);
const [rulesStatus, setRulesStatus] = useState<"LOADING" | string>("");
const setRules = async () => {
@@ -116,8 +131,23 @@ export default function Step4Rules({
setRulesStatus(e.message);
}
};
const verifyRules = async () => {
setRulesStatus("LOADING");
try {
const authToken = await getAuthToken();
if (!authToken) throw new Error("Failed to generate auth token");
const [showManualMode, setShowManualMode] = useState(false);
const isSuccessful = await checkRules(rowyRunUrl, authToken);
if (isSuccessful) {
setCompletion((c) => ({ ...c, rules: true }));
setHasRules(true);
}
setRulesStatus("");
} catch (e: any) {
console.error(e);
setRulesStatus(e.message);
}
};
// const handleSkip = () => {
// requestConfirmation({
@@ -140,81 +170,81 @@ export default function Step4Rules({
admins will need write access.
</Typography>
<SetupItem
status={hasRules ? "complete" : "incomplete"}
title={
hasRules
? "Firestore Rules are set up."
: "Add the following rules to enable access to Rowy configuration:"
}
>
{!hasRules && (
<>
<FormControlLabel
control={
<Checkbox
checked={adminRule}
onChange={(e) => setAdminRule(e.target.checked)}
/>
}
label="Allow admins to read and write all documents"
sx={{ "&&": { ml: -11 / 8, mb: -11 / 8 }, width: "100%" }}
/>
<Typography>
<InfoIcon
aria-label="Info"
sx={{ fontSize: 18, mr: 11 / 8, verticalAlign: "sub" }}
{!hasRules && error !== "security-rules/not-found" && (
<SetupItem
status="incomplete"
title="Add the following rules to enable access to Rowy configuration:"
>
<FormControlLabel
control={
<Checkbox
checked={adminRule}
onChange={(e) => setAdminRule(e.target.checked)}
/>
We removed an insecure rule that allows anyone to access any part
of your database
</Typography>
}
label="Allow admins to read and write all documents"
sx={{ "&&": { ml: -11 / 8, mb: -11 / 8 }, width: "100%" }}
/>
<DiffEditor
original={currentRules}
modified={newRules}
containerProps={{ sx: { width: "100%" } }}
minHeight={400}
options={{ renderValidationDecorations: "off" }}
<Typography>
<InfoIcon
aria-label="Info"
sx={{ fontSize: 18, mr: 11 / 8, verticalAlign: "sub" }}
/>
<Typography
variant="inherit"
color={
rulesStatus !== "LOADING" && rulesStatus ? "error" : undefined
}
>
Please verify the new rules first.
We removed an insecure rule that allows anyone to access any part of
your database
</Typography>
<DiffEditor
original={currentRules}
modified={newRules}
containerProps={{ sx: { width: "100%" } }}
minHeight={400}
options={{ renderValidationDecorations: "off" }}
/>
<Typography
variant="inherit"
color={
rulesStatus !== "LOADING" && rulesStatus ? "error" : undefined
}
>
Please verify the new rules first.
</Typography>
<LoadingButton
variant="contained"
color="primary"
onClick={setRules}
loading={rulesStatus === "LOADING"}
>
Set Firestore Rules
</LoadingButton>
{rulesStatus !== "LOADING" && typeof rulesStatus === "string" && (
<Typography variant="caption" color="error">
{rulesStatus}
</Typography>
<LoadingButton
variant="contained"
color="primary"
onClick={setRules}
loading={rulesStatus === "LOADING"}
)}
{!showManualMode && (
<Link
component="button"
variant="body2"
onClick={() => setShowManualMode(true)}
>
Set Firestore Rules
</LoadingButton>
{rulesStatus !== "LOADING" && typeof rulesStatus === "string" && (
<Typography variant="caption" color="error">
{rulesStatus}
</Typography>
)}
{!showManualMode && (
<Link
component="button"
variant="body2"
onClick={() => setShowManualMode(true)}
>
Alternatively, add these rules in the Firebase Console
</Link>
)}
</>
)}
</SetupItem>
Alternatively, add these rules in the Firebase Console
</Link>
)}
</SetupItem>
)}
{!hasRules && showManualMode && (
<SetupItem
status="incomplete"
title="Alternatively, you can add these rules in the Firebase Console."
title={
error === "security-rules/not-found"
? "Add the following rules in the Firebase Console to enable access to Rowy configuration:"
: "Alternatively, you can add these rules in the Firebase Console."
}
>
<Typography
variant="caption"
@@ -258,10 +288,30 @@ export default function Step4Rules({
<InlineOpenInNewIcon />
</Button>
</Grid>
<Grid item>
<LoadingButton
variant="contained"
color="primary"
onClick={verifyRules}
loading={rulesStatus === "LOADING"}
>
Verify
</LoadingButton>
{rulesStatus !== "LOADING" && typeof rulesStatus === "string" && (
<Typography variant="caption" color="error">
{rulesStatus}
</Typography>
)}
</Grid>
</Grid>
</div>
</SetupItem>
)}
{hasRules && (
<SetupItem status="complete" title="Firestore Rules are set up." />
)}
</>
);
}

View File

@@ -12,7 +12,7 @@ import { CONFIG } from "@src/config/dbPaths";
import { rowyRun } from "@src/utils/rowyRun";
import { runRoutes } from "@src/constants/runRoutes";
export default function Step5Migrate({
export default function Step4Migrate({
rowyRunUrl,
completion,
setCompletion,

View File

@@ -26,8 +26,8 @@ export interface IFormProps {
}
export default function Form({ values }: IFormProps) {
const { tableState, sideDrawerRef } = useProjectContext();
const { userDoc } = useAppContext();
const { userDoc, userClaims } = useAppContext();
const { table, tableState, sideDrawerRef } = useProjectContext();
const userDocHiddenFields =
userDoc.state.doc?.tables?.[`${tableState!.config.id}`]?.hiddenFields ?? [];
@@ -107,19 +107,24 @@ export default function Form({ values }: IFormProps) {
return null;
}
// Disable field if locked, or if table is read-only
const disabled =
field.editable === false ||
Boolean(table?.readOnly && !userClaims?.roles.includes("ADMIN"));
return (
<FieldWrapper
key={field.key ?? i}
type={field.type}
name={field.key}
label={field.name}
disabled={field.editable === false}
disabled={disabled}
>
{createElement(fieldComponent, {
column: field,
control,
docRef,
disabled: field.editable === false,
disabled,
useFormMethods: methods,
})}
</FieldWrapper>

View File

@@ -7,6 +7,8 @@ import {
StepProps,
StepButton,
StepButtonProps,
StepLabel,
StepLabelProps,
Typography,
StepContent,
StepContentProps,
@@ -17,17 +19,22 @@ export interface ISteppedAccordionProps extends Partial<StepperProps> {
steps: {
id: string;
title: React.ReactNode;
subtitle?: React.ReactNode;
optional?: boolean;
content: React.ReactNode;
error?: boolean;
stepProps?: Partial<StepProps>;
titleProps?: Partial<StepButtonProps>;
labelButtonProps?: Partial<StepButtonProps>;
labelProps?: Partial<StepLabelProps>;
contentProps?: Partial<StepContentProps>;
}[];
disableUnmount?: boolean;
}
export default function SteppedAccordion({
steps,
disableUnmount,
...props
}: ISteppedAccordionProps) {
const [activeStep, setActiveStep] = useState(steps[0].id);
@@ -65,25 +72,40 @@ export default function SteppedAccordion({
({
id,
title,
subtitle,
optional,
content,
error,
stepProps,
titleProps,
labelButtonProps,
labelProps,
contentProps,
}) => (
<Step key={id} {...stepProps}>
<StepButton
onClick={() => setActiveStep((s) => (s === id ? "" : id))}
optional={
optional && <Typography variant="caption">Optional</Typography>
subtitle ||
(optional && (
<Typography variant="caption">Optional</Typography>
))
}
{...titleProps}
{...labelButtonProps}
>
{title}
<ExpandIcon />
<StepLabel error={error} {...labelProps}>
{title}
<ExpandIcon />
</StepLabel>
</StepButton>
<StepContent {...contentProps}>{content}</StepContent>
<StepContent
TransitionProps={
disableUnmount ? { unmountOnExit: false } : undefined
}
{...contentProps}
>
{content}
</StepContent>
</Step>
)
)}

View File

@@ -112,7 +112,6 @@ export default function DraggableHeaderRenderer<R>({
onColumnsReorder: (sourceKey: string, targetKey: string) => void;
}) {
const classes = useStyles();
const { userClaims } = useAppContext();
const { tableState, tableActions, columnMenuRef } = useProjectContext();
const [{ isDragging }, drag] = useDrag({
@@ -149,16 +148,18 @@ export default function DraggableHeaderRenderer<R>({
anchorEl: buttonRef.current,
});
};
const _sortKey = getFieldProp("sortKey", (column as any).type);
const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key;
const isSorted = orderBy?.[0]?.key === column.key;
const isSorted = orderBy?.[0]?.key === sortKey;
const isAsc = isSorted && orderBy?.[0]?.direction === "asc";
const isDesc = isSorted && orderBy?.[0]?.direction === "desc";
const handleSortClick = () => {
let ordering: TableOrder = [];
if (!isSorted) ordering = [{ key: column.key, direction: "desc" }];
else if (isDesc) ordering = [{ key: column.key, direction: "asc" }];
if (!isSorted) ordering = [{ key: sortKey, direction: "desc" }];
else if (isDesc) ordering = [{ key: sortKey, direction: "asc" }];
else ordering = [];
tableActions.table.orderBy(ordering);

View File

@@ -7,12 +7,17 @@ import { Control, useWatch } from "react-hook-form";
export interface IAutosaveProps {
control: Control;
handleSave: (values: any) => void;
debounce?: number;
}
export default function FormAutosave({ control, handleSave }: IAutosaveProps) {
export default function FormAutosave({
control,
handleSave,
debounce = 1000,
}: IAutosaveProps) {
const values = useWatch({ control });
const [debouncedValue] = useDebounce(values, 1000, {
const [debouncedValue] = useDebounce(values, debounce, {
equalityFn: _isEqual,
});

View File

@@ -15,6 +15,7 @@ import { useConfirmation } from "@src/components/ConfirmationDialog";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import { FieldType } from "@src/constants/fields";
import { runRoutes } from "@src/constants/runRoutes";
import { useSnackbar } from "notistack";
export default function FieldSettings(props: IMenuModalProps) {
const { name, fieldName, type, open, config, handleClose, handleSave } =
@@ -27,6 +28,7 @@ export default function FieldSettings(props: IMenuModalProps) {
const initializable = getFieldProp("initializable", type);
const { requestConfirmation } = useConfirmation();
const { enqueueSnackbar } = useSnackbar();
const { tableState, rowyRun } = useProjectContext();
const snackLogContext = useSnackLogContext();
@@ -157,29 +159,34 @@ export default function FieldSettings(props: IMenuModalProps) {
});
return;
}
if (showRebuildPrompt) {
requestConfirmation({
title: "Deploy changes",
body: "You have made changes that affect the behavior of the cloud function of this table, Would you like to redeploy it now?",
confirm: "Deploy",
cancel: "Later",
handleConfirm: async () => {
if (!rowyRun) return;
snackLogContext.requestSnackLog();
rowyRun({
route: runRoutes.buildFunction,
body: {
tablePath: tableState?.tablePath,
pathname: window.location.pathname,
tableConfigPath: tableState?.config.tableConfig.path,
},
params: [],
});
},
enqueueSnackbar("Saving changes...", {
autoHideDuration: 1500,
});
handleSave(fieldName, { config: newConfig }, () => {
requestConfirmation({
title: "Deploy changes",
body: "You have made changes that affect the behavior of the cloud function of this table, Would you like to redeploy it now?",
confirm: "Deploy",
cancel: "Later",
handleConfirm: async () => {
if (!rowyRun) return;
snackLogContext.requestSnackLog();
rowyRun({
route: runRoutes.buildFunction,
body: {
tablePath: tableState?.tablePath,
pathname: window.location.pathname,
tableConfigPath: tableState?.config.tableConfig.path,
},
});
},
});
});
} else {
handleSave(fieldName, { config: newConfig });
}
handleSave(fieldName, { config: newConfig });
handleClose();
setShowRebuildPrompt(false);
},

View File

@@ -1,12 +1,6 @@
import { Fragment } from "react";
import {
MenuItem,
ListItemIcon,
ListSubheader,
Divider,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import { MenuItem, ListItemIcon, ListSubheader, Divider } from "@mui/material";
export interface IMenuContentsProps {
menuItems: {
@@ -43,38 +37,11 @@ export default function MenuContents({ menuItems }: IMenuContentsProps) {
<MenuItem
key={index}
onClick={item.onClick}
sx={
item.color === "error"
? {
color: "error.main",
"&:hover": {
backgroundColor: (theme) =>
alpha(
theme.palette.error.main,
theme.palette.action.hoverOpacity
),
},
}
: undefined
}
color={item.color}
selected={item.active}
disabled={item.disabled}
>
<ListItemIcon
sx={
item.color === "error"
? {
color: (theme) =>
alpha(
theme.palette.error.main,
theme.palette.action.activeOpacity * 1.1
),
}
: undefined
}
>
{icon}
</ListItemIcon>
<ListItemIcon>{icon}</ListItemIcon>
{item.active ? item.activeLabel : item.label}
</MenuItem>
);

View File

@@ -27,10 +27,8 @@ export default function NewColumn({
data,
openSettings,
handleClose,
handleSave,
}: INewColumnProps) {
const { table, settingsActions } = useProjectContext();
const { settingsActions, table, tableActions } = useProjectContext();
const [columnLabel, setColumnLabel] = useState("");
const [fieldKey, setFieldKey] = useState("");
const [type, setType] = useState(FieldType.shortText);
@@ -139,14 +137,19 @@ export default function NewColumn({
actions={{
primary: {
onClick: () => {
handleSave(fieldKey, {
type,
name: columnLabel,
fieldName: fieldKey,
key: fieldKey,
config: {},
...data.initializeColumn,
});
tableActions?.column.insert(
{
type,
name: columnLabel,
fieldName: fieldKey,
key: fieldKey,
config: {},
},
{
insert: data.insert,
index: data.sourceIndex,
}
);
if (requireConfiguration) {
openSettings({
type,
@@ -154,7 +157,6 @@ export default function NewColumn({
fieldName: fieldKey,
key: fieldKey,
config: {},
...data.initializeColumn,
});
} else handleClose();
analytics.logEvent("create_column", {

View File

@@ -51,6 +51,7 @@ type SelectedColumnHeader = {
column: Column<any> & { [key: string]: any };
anchorEl: PopoverProps["anchorEl"];
};
export type ColumnMenuRef = {
selectedColumnHeader: SelectedColumnHeader | null;
setSelectedColumnHeader: React.Dispatch<
@@ -67,7 +68,11 @@ export interface IMenuModalProps {
config: Record<string, any>;
handleClose: () => void;
handleSave: (fieldName: string, config: Record<string, any>) => void;
handleSave: (
fieldName: string,
config: Record<string, any>,
onSuccess?: Function
) => void;
}
export default function ColumnMenu() {
@@ -88,9 +93,7 @@ export default function ColumnMenu() {
if (column && column.type === FieldType.last) {
setModal({
type: ModalStates.new,
data: {
initializeColumn: { index: column.index ? column.index + 1 : 0 },
},
data: {},
});
}
}, [column]);
@@ -114,8 +117,9 @@ export default function ColumnMenu() {
);
if (!column) return null;
const isSorted = orderBy?.[0]?.key === column.key;
const _sortKey = getFieldProp("sortKey", (column as any).type);
const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key;
const isSorted = orderBy?.[0]?.key === sortKey;
const isAsc = isSorted && orderBy?.[0]?.direction === "asc";
const clearModal = () => {
@@ -123,8 +127,12 @@ export default function ColumnMenu() {
setTimeout(() => handleClose(), 300);
};
const handleModalSave = (key: string, update: Record<string, any>) => {
actions.update(key, update);
const handleModalSave = (
key: string,
update: Record<string, any>,
onSuccess?: Function
) => {
actions.update(key, update, onSuccess);
};
const openSettings = (column) => {
setSelectedColumnHeader({
@@ -172,7 +180,7 @@ export default function ColumnMenu() {
icon: <ArrowDownwardIcon />,
onClick: () => {
tableActions.table.orderBy(
isSorted && !isAsc ? [] : [{ key: column.key, direction: "desc" }]
isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }]
);
handleClose();
},
@@ -185,7 +193,7 @@ export default function ColumnMenu() {
icon: <ArrowUpwardIcon />,
onClick: () => {
tableActions.table.orderBy(
isSorted && isAsc ? [] : [{ key: column.key, direction: "asc" }]
isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }]
);
handleClose();
},
@@ -200,7 +208,8 @@ export default function ColumnMenu() {
setModal({
type: ModalStates.new,
data: {
initializeColumn: { index: column.index ? column.index - 1 : 0 },
insert: "left",
sourceIndex: column.index,
},
}),
},
@@ -211,7 +220,8 @@ export default function ColumnMenu() {
setModal({
type: ModalStates.new,
data: {
initializeColumn: { index: column.index ? column.index + 1 : 0 },
insert: "right",
sourceIndex: column.index,
},
}),
},
@@ -342,6 +352,7 @@ export default function ColumnMenu() {
open={modal.type === ModalStates.typeChange}
/>
<FieldSettings
key={column.key}
{...menuModalProps}
open={modal.type === ModalStates.settings}
/>

View File

@@ -0,0 +1,43 @@
import { Menu } from "@mui/material";
import MenuRow, { IMenuRow } from "./MenuRow";
interface IMenuContents {
anchorEl: HTMLElement;
open: boolean;
handleClose: () => void;
items: IMenuRow[];
}
export function MenuContents({
anchorEl,
open,
handleClose,
items,
}: IMenuContents) {
const handleContext = (e: React.MouseEvent) => e.preventDefault();
return (
<Menu
id="cell-context-menu"
aria-labelledby="cell-context-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
sx={{ "& .MuiMenu-paper": { backgroundColor: "background.default" } }}
MenuListProps={{ disablePadding: true }}
onContextMenu={handleContext}
>
{items.map((item, indx: number) => (
<MenuRow key={indx} {...item} />
))}
</Menu>
);
}

View File

@@ -0,0 +1,17 @@
import { ListItemIcon, ListItemText, MenuItem } from "@mui/material";
export interface IMenuRow {
onClick: () => void;
icon: JSX.Element;
label: string;
disabled?: boolean;
}
export default function MenuRow({ onClick, icon, label, disabled }: IMenuRow) {
return (
<MenuItem disabled={disabled} onClick={onClick}>
<ListItemIcon>{icon} </ListItemIcon>
<ListItemText> {label} </ListItemText>
</MenuItem>
);
}

View File

@@ -0,0 +1,57 @@
import React from "react";
import _find from "lodash/find";
import { PopoverProps } from "@mui/material";
import { getFieldProp } from "@src/components/fields";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { MenuContents } from "./MenuContent";
export type SelectedCell = {
rowIndex: number;
colIndex: number;
};
export type ContextMenuRef = {
selectedCell: SelectedCell;
setSelectedCell: React.Dispatch<React.SetStateAction<SelectedCell | null>>;
anchorEl: HTMLElement | null;
setAnchorEl: React.Dispatch<
React.SetStateAction<PopoverProps["anchorEl"] | null>
>;
};
export default function ContextMenu() {
const { contextMenuRef, tableState }: any = useProjectContext();
const [anchorEl, setAnchorEl] = React.useState<any | null>(null);
const [selectedCell, setSelectedCell] = React.useState<any | null>();
const open = Boolean(anchorEl);
const handleClose = () => setAnchorEl(null);
if (contextMenuRef)
contextMenuRef.current = {
anchorEl,
setAnchorEl,
selectedCell,
setSelectedCell,
} as {};
const selectedColIndex = selectedCell?.colIndex;
const selectedCol = _find(tableState?.columns, { index: selectedColIndex });
const getActions =
getFieldProp("contextMenuActions", selectedCol?.type) ||
function empty() {};
const actions = getActions() || [];
const hasNoActions = Boolean(actions.length === 0);
if (!contextMenuRef.current || hasNoActions) return null;
return (
<MenuContents
anchorEl={anchorEl}
open={open}
handleClose={handleClose}
items={actions}
/>
);
}

View File

@@ -1,316 +0,0 @@
import { useState, useEffect, Suspense, createElement } from "react";
import _find from "lodash/find";
import _sortBy from "lodash/sortBy";
import _isEmpty from "lodash/isEmpty";
import { useForm } from "react-hook-form";
import {
Popover,
Button,
IconButton,
Grid,
MenuItem,
TextField,
Chip,
InputLabel,
} from "@mui/material";
import FilterIcon from "@mui/icons-material/FilterList";
import CloseIcon from "@mui/icons-material/Close";
import ButtonWithStatus from "@src/components/ButtonWithStatus";
import FormAutosave from "@src/components/Table/ColumnMenu/FieldSettings/FormAutosave";
import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton";
import { FieldType } from "@src/constants/fields";
import { TableFilter } from "@src/hooks/useTable";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { useAppContext } from "@src/contexts/AppContext";
import { DocActions } from "@src/hooks/useDoc";
import { getFieldProp } from "@src/components/fields";
const getType = (column) =>
column.type === FieldType.derivative
? column.config.renderFieldType
: column.type;
export default function Filters() {
const { tableState, tableActions } = useProjectContext();
const { userDoc } = useAppContext();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
useEffect(() => {
if (userDoc.state.doc && tableState?.config.id) {
if (userDoc.state.doc.tables?.[tableState?.config.id]?.filters) {
tableActions?.table.filter(
userDoc.state.doc.tables[tableState?.config.id].filters
);
tableActions?.table.orderBy();
}
}
}, [userDoc.state, tableState?.config.id]);
const filterColumns = _sortBy(Object.values(tableState!.columns), "index")
.filter((c) => getFieldProp("filter", c.type))
.map((c) => ({
key: c.key,
label: c.name,
type: c.type,
options: c.options,
...c,
}));
const [selectedColumn, setSelectedColumn] = useState<any>();
const [query, setQuery] = useState<TableFilter>({
key: "",
operator: "",
value: "",
});
const [selectedFilter, setSelectedFilter] = useState<any>();
const type = selectedColumn ? getType(selectedColumn) : null;
useEffect(() => {
if (selectedColumn) {
const _filter = getFieldProp("filter", selectedColumn.type);
setSelectedFilter(_filter);
let updatedQuery: TableFilter = {
key: selectedColumn.key,
operator: _filter.operators[0].value,
value: _filter.defaultValue,
};
setQuery(updatedQuery);
}
}, [selectedColumn]);
const handleClose = () => setAnchorEl(null);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(anchorEl ? null : event.currentTarget);
};
const handleChangeColumn = (e) => {
const column = _find(filterColumns, (c) => c.key === e.target.value);
setSelectedColumn(column);
};
const open = Boolean(anchorEl);
const id = open ? "simple-popper" : undefined;
const handleUpdateFilters = (filters: TableFilter[]) => {
userDoc.dispatch({
action: DocActions.update,
data: {
tables: { [`${tableState?.config.id}`]: { filters } },
},
});
};
const { control } = useForm({
mode: "onBlur",
});
return (
<>
<Grid container direction="row" wrap="nowrap" style={{ width: "auto" }}>
<ButtonWithStatus
variant="outlined"
color="primary"
onClick={handleClick}
startIcon={<FilterIcon />}
active={tableState?.filters && tableState?.filters.length > 0}
sx={
tableState?.filters && tableState?.filters.length > 0
? {
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
position: "relative",
zIndex: 1,
}
: {}
}
>
{tableState?.filters && tableState?.filters.length > 0
? "Filtered"
: "Filter"}
</ButtonWithStatus>
{(tableState?.filters ?? []).map((filter) => (
<Chip
key={filter.key}
label={`${filter.key} ${filter.operator} ${
selectedFilter?.valueFormatter
? selectedFilter.valueFormatter(filter.value)
: filter.value
}`}
onDelete={() => handleUpdateFilters([])}
sx={{
borderRadius: 1,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: "none",
backgroundColor: "background.paper",
height: 32,
"& .MuiChip-label": { px: 1.5 },
}}
variant="outlined"
/>
))}
</Grid>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
sx={{
"& .MuiPaper-root": { width: 640 },
"& .content": { py: 3, px: 2 },
}}
>
<IconButton
onClick={handleClose}
sx={{
position: "absolute",
top: (theme) => theme.spacing(0.5),
right: (theme) => theme.spacing(0.5),
}}
>
<CloseIcon />
</IconButton>
<div className="content">
<Grid container spacing={2}>
<Grid item xs={4}>
<TextField
label="Column"
select
variant="filled"
hiddenLabel
fullWidth
value={selectedColumn?.key ?? ""}
onChange={handleChangeColumn}
SelectProps={{ displayEmpty: true }}
>
<MenuItem disabled value="" style={{ display: "none" }}>
Select column
</MenuItem>
{filterColumns.map((c) => (
<MenuItem key={c.key} value={c.key}>
{c.label}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={4}>
<TextField
label="Condition"
select
variant="filled"
hiddenLabel
fullWidth
value={query.operator}
disabled={!query.key || selectedFilter?.operators?.length === 0}
onChange={(e) => {
setQuery((query) => ({
...query,
operator: e.target.value as string,
}));
}}
SelectProps={{ displayEmpty: true }}
>
<MenuItem disabled value="" style={{ display: "none" }}>
Select condition
</MenuItem>
{selectedFilter?.operators.map((operator) => (
<MenuItem key={operator.value} value={operator.value}>
{operator.label}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={4}>
{query.key && query.operator && (
<form>
<InputLabel
variant="filled"
id={`filters-label-${query.key}`}
htmlFor={`sidedrawer-field-${query.key}`}
>
Value
</InputLabel>
<FormAutosave
control={control}
handleSave={(values) =>
setQuery((query) => ({
...query,
value: values[query.key],
}))
}
/>
<Suspense fallback={<FieldSkeleton />}>
{query.operator &&
createElement(getFieldProp("SideDrawerField", type), {
column: selectedColumn,
control,
docRef: {},
disabled: false,
onChange: () => {},
})}
</Suspense>
</form>
)}
</Grid>
</Grid>
<Grid
container
sx={{
mt: 3,
"& .MuiButton-root": { minWidth: 100 },
}}
justifyContent="center"
spacing={1}
>
<Grid item>
<Button
disabled={query.key === ""}
onClick={() => {
handleUpdateFilters([]);
setQuery({
key: "",
operator: "",
value: "",
});
setSelectedColumn(null);
}}
>
Clear
</Button>
</Grid>
<Grid item>
<Button
disabled={
query.value !== true &&
query.value !== false &&
_isEmpty(query.value) &&
typeof query.value !== "number" &&
typeof query.value !== "object"
}
color="primary"
variant="contained"
onClick={() => {
handleUpdateFilters([query]);
handleClose();
}}
>
Apply
</Button>
</Grid>
</Grid>
</div>
</Popover>
</>
);
}

View File

@@ -1,52 +1,18 @@
import { Column } from "react-data-grid";
import { makeStyles, createStyles } from "@mui/styles";
import { Grid, Button } from "@mui/material";
import { Button } from "@mui/material";
import AddColumnIcon from "@src/assets/icons/AddColumn";
import { useAppContext } from "@src/contexts/AppContext";
import { useProjectContext } from "@src/contexts/ProjectContext";
const useStyles = makeStyles((theme) =>
createStyles({
"@global": {
".rdg-header-row .rdg-cell.final-column-header": {
border: "none",
".rdg.rdg &": { padding: theme.spacing(0, 0.75) },
position: "relative",
"&::before": {
content: "''",
display: "block",
width: 43,
height: "100%",
position: "absolute",
top: 0,
left: 0,
border: "1px solid var(--border-color)",
borderLeftWidth: 0,
borderTopRightRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
},
},
},
root: {
height: "100%",
width: "auto",
},
button: { zIndex: 1 },
})
);
const FinalColumnHeader: Column<any>["headerRenderer"] = ({ column }) => {
const classes = useStyles();
const { userClaims } = useAppContext();
const { columnMenuRef } = useProjectContext();
if (!columnMenuRef) return null;
if (!userClaims?.roles.includes("ADMIN")) return null;
const handleClick = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) =>
@@ -56,22 +22,15 @@ const FinalColumnHeader: Column<any>["headerRenderer"] = ({ column }) => {
});
return (
<Grid
container
alignItems="center"
justifyContent="center"
className={classes.root}
<Button
onClick={handleClick}
variant="contained"
color="primary"
startIcon={<AddColumnIcon />}
style={{ zIndex: 1 }}
>
<Button
onClick={handleClick}
variant="contained"
color="primary"
className={classes.button}
startIcon={<AddColumnIcon />}
>
Add column
</Button>
</Grid>
Add column
</Button>
);
};

View File

@@ -104,12 +104,43 @@ export const TableContainer = styled("div", {
".rdg-row, .rdg-header-row": {
marginLeft: `max(env(safe-area-inset-left), ${theme.spacing(2)})`,
marginRight: `env(safe-area-inset-right)`,
marginRight: `max(env(safe-area-inset-right), ${theme.spacing(8)})`,
display: "inline-grid", // Fix Safari not showing margin-right
},
".rdg-header-row .rdg-cell:first-child": {
borderTopLeftRadius: theme.shape.borderRadius,
},
".rdg-header-row .rdg-cell:last-child": {
borderTopRightRadius: theme.shape.borderRadius,
},
".rdg-header-row .rdg-cell.final-column-header": {
border: "none",
padding: theme.spacing(0, 0.75),
borderBottomRightRadius: theme.shape.borderRadius,
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
position: "relative",
"&::before": {
content: "''",
display: "block",
width: 88,
height: "100%",
position: "absolute",
top: 0,
left: 0,
border: "1px solid var(--border-color)",
borderLeftWidth: 0,
borderTopRightRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
},
},
".rdg-row .rdg-cell:first-child, .rdg-header-row .rdg-cell:first-child": {
borderLeft: "1px solid var(--border-color)",

View File

@@ -1,16 +1,25 @@
import { useProjectContext } from "@src/contexts/ProjectContext";
import { Fragment } from "react";
import { Row, RowRendererProps } from "react-data-grid";
import OutOfOrderIndicator from "./OutOfOrderIndicator";
export default function TableRow(props: RowRendererProps<any>) {
const { contextMenuRef }: any = useProjectContext();
const handleContextMenu = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
e.preventDefault();
if (contextMenuRef?.current) contextMenuRef?.current?.setAnchorEl(e.target);
};
if (props.row._rowy_outOfOrder)
return (
<Fragment key={props.row.id}>
<OutOfOrderIndicator top={props.top} height={props.height} />
<Row {...props} />
<Row {...props} onContextMenu={handleContextMenu} />
</Fragment>
);
return <Row {...props} />;
return <Row {...props} onContextMenu={handleContextMenu} />;
}

View File

@@ -28,11 +28,15 @@ export default function FinalColumn({ row }: FormatterProps<any, any>) {
useStyles();
const { requestConfirmation } = useConfirmation();
const { deleteRow, addRow } = useProjectContext();
const { deleteRow, addRow, table } = useProjectContext();
const altPress = useKeyPress("Alt");
const handleDelete = () => {
if (deleteRow) deleteRow(row.id);
};
if (table?.readOnly) return null;
return (
<Stack direction="row" spacing={0.5}>
<Tooltip title="Duplicate row">

View File

@@ -19,6 +19,7 @@ import TableContainer, { OUT_OF_ORDER_MARGIN } from "./TableContainer";
import TableHeader from "../TableHeader";
import ColumnHeader from "./ColumnHeader";
import ColumnMenu from "./ColumnMenu";
import ContextMenu from "./ContextMenu";
import FinalColumnHeader from "./FinalColumnHeader";
import FinalColumn from "./formatters/FinalColumn";
import TableRow from "./TableRow";
@@ -43,9 +44,16 @@ const rowClass = (row: any) => (row._rowy_outOfOrder ? "out-of-order" : "");
//const SelectColumn = { ..._SelectColumn, width: 42, maxWidth: 42 };
export default function Table() {
const { tableState, tableActions, dataGridRef, sideDrawerRef, updateCell } =
useProjectContext();
const { userDoc } = useAppContext();
const {
table,
tableState,
tableActions,
dataGridRef,
contextMenuRef,
sideDrawerRef,
updateCell,
} = useProjectContext();
const { userDoc, userClaims } = useAppContext();
const userDocHiddenFields =
userDoc.state.doc?.tables?.[formatSubTableName(tableState?.config.id)]
@@ -63,7 +71,6 @@ export default function Table() {
)
.map((column: any) => ({
draggable: true,
editable: true,
resizable: true,
frozen: column.fixed,
headerRenderer: ColumnHeader,
@@ -84,6 +91,10 @@ export default function Table() {
return null;
},
...column,
editable:
table?.readOnly && !userClaims?.roles.includes("ADMIN")
? false
: column.editable ?? true,
width: (column.width as number)
? (column.width as number) > 380
? 380
@@ -92,28 +103,36 @@ export default function Table() {
}))
.filter((column) => !userDocHiddenFields.includes(column.key));
setColumns([
// SelectColumn,
..._columns,
{
if (!table?.readOnly || userClaims?.roles.includes("ADMIN")) {
_columns.push({
isNew: true,
key: "new",
name: "Add column",
type: FieldType.last,
index: _columns.length ?? 0,
width: 204,
width: 154,
headerRenderer: FinalColumnHeader,
headerCellClass: "final-column-header",
cellClass: "final-column-cell",
formatter: FinalColumn,
editable: false,
},
]);
});
}
setColumns(_columns);
// setColumns([
// // SelectColumn,
// ..._columns,
// ,
// ]);
}
}, [
tableState?.loadingColumns,
tableState?.columns,
JSON.stringify(userDocHiddenFields),
table?.readOnly,
userClaims?.roles,
]);
const rows =
@@ -245,6 +264,12 @@ export default function Table() {
});
}
}}
onSelectedCellChange={({ rowIdx, idx }) => {
contextMenuRef?.current?.setSelectedCell({
rowIndex: rowIdx,
colIndex: idx,
});
}}
/>
</DndProvider>
) : (
@@ -253,6 +278,7 @@ export default function Table() {
</TableContainer>
<ColumnMenu />
<ContextMenu />
<BulkActions
selectedRows={selectedRows}
columns={columns}

View File

@@ -8,10 +8,12 @@ import {
Select,
MenuItem,
ListItemText,
Box,
} from "@mui/material";
import AddRowIcon from "@src/assets/icons/AddRow";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import { useAppContext } from "@src/contexts/AppContext";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { isCollectionGroup } from "@src/utils/fns";
import { db } from "@src/firebase";
@@ -19,7 +21,8 @@ import { db } from "@src/firebase";
const useIdTypeState = createPersistedState("__ROWY__ADD_ROW_ID_TYPE");
export default function AddRow() {
const { addRow, tableState } = useProjectContext();
const { userClaims } = useAppContext();
const { addRow, table, tableState } = useProjectContext();
const anchorEl = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
@@ -38,6 +41,9 @@ export default function AddRow() {
}
};
if (table?.readOnly && !userClaims?.roles.includes("ADMIN"))
return <Box sx={{ mr: -2 }} />;
return (
<>
<ButtonGroup
@@ -45,11 +51,11 @@ export default function AddRow() {
color="primary"
aria-label="Split button"
ref={anchorEl}
disabled={isCollectionGroup() || !addRow}
>
<Button
variant="contained"
color="primary"
disabled={isCollectionGroup() || !addRow}
onClick={handleClick}
startIcon={<AddRowIcon />}
>

View File

@@ -15,7 +15,8 @@ import CircularProgressOptical from "@src/components/CircularProgressOptical";
import { isTargetInsideBox } from "utils/fns";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import useBuildLogs from "./useBuildLogs";
import { modalAtom, cloudLogFiltersAtom } from "../utils";
import { modalAtom } from "@src/atoms/Table";
import { cloudLogFiltersAtom } from "../utils";
export interface IBuildLogsSnackProps {
onClose: () => void;
@@ -25,12 +26,11 @@ export interface IBuildLogsSnackProps {
export default function BuildLogsSnack({ onClose, onOpenPanel }) {
const snackLogContext = useSnackLogContext();
const { latestLog } = useBuildLogs();
const [, setModal] = useAtom(modalAtom);
const [, setCloudLogFilters] = useAtom(cloudLogFiltersAtom);
const latestActiveLog =
latestLog?.startTimeStamp > snackLogContext.latestBuildTimestamp
latestLog?.startTimeStamp > snackLogContext.latestBuildTimestamp - 5000 ||
latestLog?.startTimeStamp > snackLogContext.latestBuildTimestamp + 5000
? latestLog
: null;
const logs = latestActiveLog?.fullLog;

View File

@@ -12,17 +12,15 @@ export default function useBuildLogs() {
useEffect(() => {
if (functionConfigPath) {
const path = `${functionConfigPath}/buildLogs`;
// console.log(path);
collectionDispatch({
path,
orderBy: [{ key: "startTimeStamp", direction: "desc" }],
limit: 30,
limit: 15,
});
}
}, [functionConfigPath]);
const latestLog = collectionState?.documents?.[0];
const latestStatus = latestLog?.status;
return { collectionState, latestLog, latestStatus };
}

View File

@@ -189,6 +189,16 @@ export default function CloudLogItem({
<Typography variant="inherit" noWrap className="log-preview">
{data.payload === "textPayload" && data.textPayload}
{_get(data, "httpRequest.requestUrl")?.split(".run.app").pop()}
{data.payload === "jsonPayload" && (
<Typography
variant="inherit"
color="error"
fontWeight="bold"
component="span"
>
{data.jsonPayload.error}{" "}
</Typography>
)}
{data.payload === "jsonPayload" &&
stringify(data.jsonPayload.body ?? data.jsonPayload, {
space: 2,
@@ -203,16 +213,29 @@ export default function CloudLogItem({
</Typography>
)}
{data.payload === "jsonPayload" && (
<ReactJson
src={data.jsonPayload.body ?? data.jsonPayload}
name={data.jsonPayload.body ? "body" : "jsonPayload"}
theme={theme.palette.mode === "dark" ? "monokai" : "rjv-default"}
iconStyle="triangle"
style={{ font: "inherit", backgroundColor: "transparent" }}
displayDataTypes={false}
quotesOnKeys={false}
sortKeys
/>
<>
{data.payload === "jsonPayload" && data.jsonPayload.error && (
<Typography
variant="inherit"
color="error"
fontWeight="bold"
paragraph
style={{ whiteSpace: "pre-wrap" }}
>
{data.jsonPayload.error}
</Typography>
)}
<ReactJson
src={data.jsonPayload.body ?? data.jsonPayload}
name={data.jsonPayload.body ? "body" : "jsonPayload"}
theme={theme.palette.mode === "dark" ? "monokai" : "rjv-default"}
iconStyle="triangle"
style={{ font: "inherit", backgroundColor: "transparent" }}
displayDataTypes={false}
quotesOnKeys={false}
sortKeys
/>
</>
)}
{data.payload && <Divider sx={{ my: 1 }} />}

View File

@@ -1,5 +1,6 @@
import useSWR from "swr";
import { useAtom } from "jotai";
import _startCase from "lodash/startCase";
import {
LinearProgress,
@@ -9,6 +10,7 @@ import {
Typography,
TextField,
InputAdornment,
Button,
} from "@mui/material";
import RefreshIcon from "@mui/icons-material/Refresh";
import LogsIcon from "@src/assets/icons/CloudLogs";
@@ -20,20 +22,28 @@ import TimeRangeSelect from "./TimeRangeSelect";
import CloudLogList from "./CloudLogList";
import BuildLogs from "./BuildLogs";
import EmptyState from "@src/components/EmptyState";
import CloudLogSeverityIcon, { SEVERITY_LEVELS } from "./CloudLogSeverityIcon";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { useAppContext } from "@src/contexts/AppContext";
import { cloudLogFiltersAtom, cloudLogFetcher } from "./utils";
export default function CloudLogsModal(props: IModalProps) {
const { rowyRun, tableState, table, compatibleRowyRunVersion } =
useProjectContext();
const { projectId } = useAppContext();
const [cloudLogFilters, setCloudLogFilters] = useAtom(cloudLogFiltersAtom);
const { data, mutate, isValidating } = useSWR(
cloudLogFilters.type === "build"
? null
: ["/logs", rowyRun, cloudLogFilters, tableState?.tablePath || ""],
: [
"/logs",
rowyRun,
projectId,
cloudLogFilters,
tableState?.tablePath || "",
],
cloudLogFetcher,
{
fallbackData: [],
@@ -90,6 +100,7 @@ export default function CloudLogsModal(props: IModalProps) {
aria-label="Filter by log type"
>
<ToggleButton value="webhook">Webhooks</ToggleButton>
<ToggleButton value="functions">Functions</ToggleButton>
<ToggleButton value="audit">Audit</ToggleButton>
<ToggleButton value="build">Build</ToggleButton>
</ToggleButtonGroup>
@@ -188,6 +199,50 @@ export default function CloudLogsModal(props: IModalProps) {
</Typography>
)}
<MultiSelect
aria-label="Severity"
labelPlural="severity levels"
options={Object.keys(SEVERITY_LEVELS)}
value={cloudLogFilters.severity ?? []}
onChange={(severity) =>
setCloudLogFilters((prev) => ({ ...prev, severity }))
}
TextFieldProps={{
style: { width: 130 },
placeholder: "Severity",
SelectProps: {
renderValue: () => {
if (
!Array.isArray(cloudLogFilters.severity) ||
cloudLogFilters.severity.length === 0
)
return `Severity`;
if (cloudLogFilters.severity.length === 1)
return (
<>
Severity{" "}
<CloudLogSeverityIcon
severity={cloudLogFilters.severity[0]}
style={{ marginTop: -2, marginBottom: -7 }}
/>
</>
);
return `Severity (${cloudLogFilters.severity.length})`;
},
},
}}
itemRenderer={(option) => (
<>
<CloudLogSeverityIcon
severity={option.value}
sx={{ mr: 1 }}
/>
{_startCase(option.value.toLowerCase())}
</>
)}
/>
<TimeRangeSelect
aria-label="Time range"
value={cloudLogFilters.timeRange}
@@ -223,7 +278,30 @@ export default function CloudLogsModal(props: IModalProps) {
{cloudLogFilters.type === "build" ? (
<BuildLogs />
) : Array.isArray(data) && data.length > 0 ? (
<CloudLogList items={data} sx={{ mx: -1.5, mt: 1.5 }} />
<>
<CloudLogList items={data} sx={{ mx: -1.5, mt: 1.5 }} />
{cloudLogFilters.timeRange.type !== "range" && (
<Button
style={{
marginLeft: "auto",
marginRight: "auto",
display: "flex",
}}
onClick={() =>
setCloudLogFilters((c) => ({
...c,
timeRange: {
...c.timeRange,
value: (c.timeRange as any).value * 2,
},
}))
}
>
Load more (last {cloudLogFilters.timeRange.value * 2}{" "}
{cloudLogFilters.timeRange.type})
</Button>
)}
</>
) : isValidating ? (
<EmptyState
Icon={LogsIcon}

View File

@@ -4,11 +4,9 @@ import TableHeaderButton from "../TableHeaderButton";
import LogsIcon from "@src/assets/icons/CloudLogs";
import CloudLogsModal from "./CloudLogsModal";
import { modalAtom } from "./utils";
import { modalAtom } from "@src/atoms/Table";
export interface ICloudLogsProps {}
export default function CloudLogs(props: ICloudLogsProps) {
export default function CloudLogs() {
const [modal, setModal] = useAtom(modalAtom);
const open = modal === "cloudLogs";
const setOpen = (open: boolean) => setModal(open ? "cloudLogs" : "");

View File

@@ -2,14 +2,14 @@ import { atomWithHash } from "jotai/utils";
import { sub } from "date-fns";
import type { IProjectContext } from "@src/contexts/ProjectContext";
export const modalAtom = atomWithHash<"cloudLogs" | "">("modal", "");
import { SEVERITY_LEVELS } from "./CloudLogSeverityIcon";
export type CloudLogFilters = {
type: "webhook" | "audit" | "build";
type: "webhook" | "functions" | "audit" | "build";
timeRange:
| { type: "seconds" | "minutes" | "hours" | "days"; value: number }
| { type: "range"; start: Date; end: Date };
severity?: Array<keyof typeof SEVERITY_LEVELS>;
webhook?: string[];
auditRowId?: string;
buildLogExpanded?: number;
@@ -26,6 +26,7 @@ export const cloudLogFiltersAtom = atomWithHash<CloudLogFilters>(
export const cloudLogFetcher = (
endpointRoot: string,
rowyRun: IProjectContext["rowyRun"],
projectId: string,
cloudLogFilters: CloudLogFilters,
tablePath: string
) => {
@@ -34,7 +35,9 @@ export const cloudLogFetcher = (
switch (cloudLogFilters.type) {
case "webhook":
logQuery.push(`logName = "projects/rowyio/logs/rowy-webhook-events"`);
logQuery.push(
`logName = "projects/${projectId}/logs/rowy-webhook-events"`
);
logQuery.push(`jsonPayload.url : "${tablePath}"`);
if (
Array.isArray(cloudLogFilters.webhook) &&
@@ -48,7 +51,7 @@ export const cloudLogFetcher = (
break;
case "audit":
logQuery.push(`logName = "projects/rowyio/logs/rowy-audit"`);
logQuery.push(`logName = "projects/${projectId}/logs/rowy-audit"`);
logQuery.push(`jsonPayload.ref.collectionPath = "${tablePath}"`);
if (cloudLogFilters.auditRowId)
logQuery.push(
@@ -56,7 +59,9 @@ export const cloudLogFetcher = (
);
break;
// logQuery.push(`resource.labels.function_name="R-githubStars"`);
case "functions":
logQuery.push(`resource.labels.function_name = "R-${tablePath}"`);
break;
default:
break;
@@ -74,6 +79,13 @@ export const cloudLogFetcher = (
}
}
if (
Array.isArray(cloudLogFilters.severity) &&
cloudLogFilters.severity.length > 0
) {
logQuery.push(`severity = (${cloudLogFilters.severity.join(" OR ")})`);
}
const logQueryUrl =
endpointRoot +
(logQuery.length > 0

View File

@@ -1,4 +1,4 @@
import { useState, useContext } from "react";
import { useState } from "react";
import { parse as json2csv } from "json2csv";
import { saveAs } from "file-saver";
import { useSnackbar } from "notistack";
@@ -9,7 +9,16 @@ import _sortBy from "lodash/sortBy";
import { isString } from "lodash";
import MultiSelect from "@rowy/multiselect";
import { Button, DialogActions } from "@mui/material";
import {
Button,
DialogActions,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
FormHelperText,
} from "@mui/material";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { FieldType } from "@src/constants/fields";
@@ -100,7 +109,7 @@ export default function Export({ query, closeModal }) {
const { enqueueSnackbar } = useSnackbar();
const [columns, setColumns] = useState<any[]>([]);
const [exportType, setExportType] = useState<"csv" | "json">("csv");
const [exportType, setExportType] = useState<"csv" | "tsv" | "json">("csv");
const handleClose = () => {
closeModal();
@@ -130,10 +139,14 @@ export default function Export({ query, closeModal }) {
.id!}-${new Date().toISOString()}.${exportType}`;
switch (exportType) {
case "csv":
case "tsv":
const csvData = docs.map((doc: any) =>
columns.reduce(selectedColumnsCsvReducer(doc), {})
);
const csv = json2csv(csvData);
const csv = json2csv(
csvData,
exportType === "tsv" ? { delimiter: "\t" } : undefined
);
const csvBlob = new Blob([csv], {
type: `text/${exportType};charset=utf-8`,
});
@@ -174,23 +187,23 @@ export default function Export({ query, closeModal }) {
selectAll
/>
<MultiSelect
value={exportType}
options={[
{ label: ".json", value: "json" },
{ label: ".csv", value: "csv" },
]}
label="Export type"
onChange={(v) => {
if (v) {
setExportType(v as "csv" | "json");
}
}}
multiple={false}
searchable={false}
clearable={false}
TextFieldProps={{ helperText: "Encoding: UTF-8" }}
/>
<FormControl component="fieldset">
<FormLabel component="legend">Export type</FormLabel>
<RadioGroup
aria-label="export type"
name="export-type-radio-buttons-group"
value={exportType}
onChange={(e) => {
const v = e.target.value;
if (v) setExportType(v as "csv" | "tsv" | "json");
}}
>
<FormControlLabel value="csv" control={<Radio />} label=".csv" />
<FormControlLabel value="tsv" control={<Radio />} label=".tsv" />
<FormControlLabel value="json" control={<Radio />} label=".json" />
</RadioGroup>
<FormHelperText>Encoding: UTF-8</FormHelperText>
</FormControl>
<div style={{ flexGrow: 1, marginTop: 0 }} />

View File

@@ -1,4 +1,5 @@
import { useState, useMemo } from "react";
import { useAtom } from "jotai";
import { makeStyles, createStyles } from "@mui/styles";
import { DialogContent, Tab, Divider } from "@mui/material";
@@ -14,15 +15,16 @@ import ExportDetails from "./Export";
import DownloadDetails from "./Download";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { db } from "../../../firebase";
import { db } from "@src/firebase";
import { isCollectionGroup } from "@src/utils/fns";
import { modalAtom } from "@src/atoms/Table";
const useStyles = makeStyles((theme) =>
createStyles({
paper: {
[theme.breakpoints.up("sm")]: {
maxWidth: 440,
height: 610,
height: 640,
},
},
@@ -47,7 +49,11 @@ const useStyles = makeStyles((theme) =>
export default function Export() {
const classes = useStyles();
const [open, setOpen] = useState(false);
const [modal, setModal] = useAtom(modalAtom);
const open = modal === "export";
const setOpen = (open: boolean) => setModal(open ? "export" : "");
const [mode, setMode] = useState<"Export" | "Download">("Export");
const { tableState } = useProjectContext();
@@ -97,8 +103,8 @@ export default function Export() {
<DialogContent style={{ flexGrow: 0, flexShrink: 0 }}>
{(tableState?.filters && tableState?.filters.length !== 0) ||
(tableState?.orderBy && tableState?.orderBy.length !== 0)
? "The filters and sorting applied to the table will be used in the export."
: "No filters or sorting will be applied on the exported data."}
? "The filters and sorting applied to the table will be applied to the export"
: "No filters or sorting will be applied on the exported data"}
</DialogContent>
<TabList

View File

@@ -1,4 +1,8 @@
import { IExtensionModalStepProps } from "./ExtensionModal";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { FieldType } from "@src/constants/fields";
import MultiSelect from "@rowy/multiselect";
import { FormHelperText } from "@mui/material";
import {
Typography,
@@ -15,6 +19,12 @@ export default function Step1Triggers({
extensionObject,
setExtensionObject,
}: IExtensionModalStepProps) {
const { tableState, compatibleRowyRunVersion } = useProjectContext();
if (!tableState?.columns) return <></>;
const columnOptions = Object.values(tableState.columns)
.filter((column) => column.type !== FieldType.subTable)
.map((c) => ({ label: c.name, value: c.key }));
return (
<>
<Typography gutterBottom>
@@ -28,33 +38,65 @@ export default function Step1Triggers({
<FormGroup>
{triggerTypes.map((trigger) => (
<FormControlLabel
key={trigger}
label={trigger}
control={
<Checkbox
checked={extensionObject.triggers.includes(trigger)}
name={trigger}
onChange={() => {
setExtensionObject((extensionObject) => {
if (extensionObject.triggers.includes(trigger)) {
<>
<FormControlLabel
key={trigger}
label={trigger}
control={
<Checkbox
checked={extensionObject.triggers.includes(trigger)}
name={trigger}
onChange={() => {
setExtensionObject((extensionObject) => {
if (extensionObject.triggers.includes(trigger)) {
return {
...extensionObject,
triggers: extensionObject.triggers.filter(
(t) => t !== trigger
),
};
} else {
return {
...extensionObject,
triggers: [...extensionObject.triggers, trigger],
};
}
});
}}
/>
}
/>
{trigger === "update" &&
extensionObject.triggers.includes("update") &&
compatibleRowyRunVersion!({ minVersion: "1.2.4" }) && (
<MultiSelect
label="Tracked fields (optional)"
options={columnOptions}
value={extensionObject.trackedFields ?? []}
onChange={(trackedFields) => {
setExtensionObject((extensionObject) => {
return {
...extensionObject,
triggers: extensionObject.triggers.filter(
(t) => t !== trigger
),
trackedFields,
};
} else {
return {
...extensionObject,
triggers: [...extensionObject.triggers, trigger],
};
}
});
}}
/>
}
/>
});
}}
TextFieldProps={{
helperText: (
<>
<FormHelperText error={false} style={{ margin: 0 }}>
Only Changes to these fields will trigger the
extension. If left blank, any update will trigger
the extension.
</FormHelperText>
</>
),
FormHelperTextProps: { component: "div" } as any,
required: false,
}}
/>
)}
</>
))}
</FormGroup>
</FormControl>

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { useAtom } from "jotai";
import _isEqual from "lodash/isEqual";
import TableHeaderButton from "../TableHeaderButton";
@@ -17,6 +18,7 @@ import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import { emptyExtensionObject, IExtension, ExtensionType } from "./utils";
import { runRoutes } from "@src/constants/runRoutes";
import { analytics } from "@src/analytics";
import { modalAtom } from "@src/atoms/Table";
export default function Extensions() {
const { tableState, tableActions, rowyRun } = useProjectContext();
@@ -28,7 +30,11 @@ export default function Extensions() {
const [localExtensionsObjects, setLocalExtensionsObjects] = useState(
currentExtensionObjects
);
const [openExtensionList, setOpenExtensionList] = useState(false);
const [modal, setModal] = useAtom(modalAtom);
const open = modal === "extensions";
const setOpen = (open: boolean) => setModal(open ? "extensions" : "");
const [openMigrationGuide, setOpenMigrationGuide] = useState(false);
const [extensionModal, setExtensionModal] = useState<{
mode: "add" | "update";
@@ -45,55 +51,57 @@ export default function Extensions() {
console.log("Extension migration required.");
setOpenMigrationGuide(true);
} else {
setOpenExtensionList(true);
setOpen(true);
}
};
const handleClose = (
setOpen: React.Dispatch<React.SetStateAction<boolean>>
_setOpen: React.Dispatch<React.SetStateAction<boolean>>
) => {
if (edited) {
setOpen(true);
_setOpen(true);
requestConfirmation({
title: "Discard changes?",
confirm: "Discard",
handleConfirm: () => {
setOpen(false);
_setOpen(false);
setLocalExtensionsObjects(currentExtensionObjects);
setOpenExtensionList(false);
setOpen(false);
},
});
} else {
setOpenExtensionList(false);
setOpen(false);
}
};
const handleSaveExtensions = () => {
const handleSaveExtensions = (callback?: Function) => {
tableActions?.table.updateConfig(
"extensionObjects",
localExtensionsObjects
localExtensionsObjects,
callback
);
setOpenExtensionList(false);
setOpen(false);
};
const handleSaveDeploy = async () => {
handleSaveExtensions();
try {
if (rowyRun) {
snackLogContext.requestSnackLog();
rowyRun({
route: runRoutes.buildFunction,
body: {
tablePath: tableState?.tablePath,
pathname: window.location.pathname,
tableConfigPath: tableState?.config.tableConfig.path,
},
});
analytics.logEvent("deployed_extensions");
handleSaveExtensions(() => {
try {
if (rowyRun) {
snackLogContext.requestSnackLog();
rowyRun({
route: runRoutes.buildFunction,
body: {
tablePath: tableState?.tablePath,
pathname: window.location.pathname,
tableConfigPath: tableState?.config.tableConfig.path,
},
});
analytics.logEvent("deployed_extensions");
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
});
};
const handleAddExtension = (extensionObject: IExtension) => {
@@ -189,7 +197,7 @@ export default function Extensions() {
icon={<ExtensionIcon />}
/>
{openExtensionList && !!tableState && (
{open && !!tableState && (
<Modal
onClose={handleClose}
disableBackdropClick={edited}
@@ -227,7 +235,7 @@ export default function Extensions() {
},
secondary: {
children: "Save",
onClick: handleSaveExtensions,
onClick: () => handleSaveExtensions(),
disabled: !edited,
},
}}
@@ -236,9 +244,7 @@ export default function Extensions() {
{extensionModal && (
<ExtensionModal
handleClose={() => {
setExtensionModal(null);
}}
handleClose={() => setExtensionModal(null)}
handleAdd={handleAddExtension}
handleUpdate={handleUpdateExtension}
mode={extensionModal.mode}
@@ -248,12 +254,10 @@ export default function Extensions() {
{openMigrationGuide && (
<ExtensionMigration
handleClose={() => {
setOpenMigrationGuide(false);
}}
handleClose={() => setOpenMigrationGuide(false)}
handleUpgradeComplete={() => {
setOpenMigrationGuide(false);
setOpenExtensionList(true);
setOpen(true);
}}
/>
)}

View File

@@ -46,6 +46,8 @@ export interface IExtension {
requiredFields: string[];
extensionBody: string;
conditions: string;
trackedFields?: string[];
}
export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"];
@@ -138,6 +140,10 @@ const extensionBodyTemplate = {
],
template_id: "", // sendgrid template ID
categories: [], // helper info to categorise sendgrid emails
custom_args:{
docPath:ref.path, // optional, reference to be used for tracking email events
// add any other custom args you want to pass to sendgrid events here
},
})
}`,
apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref}) => {

View File

@@ -0,0 +1,121 @@
import { Suspense, createElement } from "react";
import { useForm } from "react-hook-form";
import { Grid, MenuItem, TextField, InputLabel } from "@mui/material";
import MultiSelect from "@rowy/multiselect";
import FormAutosave from "@src/components/Table/ColumnMenu/FieldSettings/FormAutosave";
import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton";
import type { useFilterInputs } from "./useFilterInputs";
import { FieldType } from "@src/constants/fields";
import { getFieldProp } from "@src/components/fields";
export interface IFilterInputsProps extends ReturnType<typeof useFilterInputs> {
disabled?: boolean;
}
export default function FilterInputs({
filterColumns,
selectedColumn,
handleChangeColumn,
availableFilters,
query,
setQuery,
disabled,
}: IFilterInputsProps) {
// Need to use react-hook-form with autosave for the value field,
// since we render the side drawer field for that type
const { control } = useForm({
mode: "onBlur",
defaultValues: selectedColumn ? { [selectedColumn.key]: query.value } : {},
});
// Get column type to render for the value field
const columnType = selectedColumn
? selectedColumn.type === FieldType.derivative
? selectedColumn.config.renderFieldType
: selectedColumn.type
: null;
return (
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={4}>
<MultiSelect
multiple={false}
label="Column"
options={filterColumns}
value={query.key}
onChange={handleChangeColumn}
disabled={disabled}
/>
</Grid>
<Grid item xs={4}>
<TextField
label="Operator"
select
variant="filled"
fullWidth
value={query.operator}
disabled={
disabled || !query.key || availableFilters?.operators?.length === 0
}
onChange={(e) => {
setQuery((query) => ({
...query,
operator: e.target.value as string,
}));
}}
SelectProps={{ displayEmpty: true }}
>
<MenuItem disabled value="" style={{ display: "none" }}>
Select operator
</MenuItem>
{availableFilters?.operators.map((operator) => (
<MenuItem key={operator.value} value={operator.value}>
{operator.label}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={4}>
{query.key && query.operator && (
<form>
<InputLabel
variant="filled"
id={`filters-label-${query.key}`}
htmlFor={`sidedrawer-field-${query.key}`}
>
Value
</InputLabel>
<FormAutosave
debounce={0}
control={control}
handleSave={(values) => {
if (values[query.key] !== undefined) {
setQuery((query) => ({
...query,
value: values[query.key],
}));
}
}}
/>
<Suspense fallback={<FieldSkeleton />}>
{createElement(getFieldProp("SideDrawerField", columnType), {
column: selectedColumn,
control,
docRef: {},
disabled,
onChange: () => {},
})}
</Suspense>
</form>
)}
</Grid>
</Grid>
);
}

View File

@@ -0,0 +1,106 @@
import { useRef, useState } from "react";
import { Popover, Stack, Chip } from "@mui/material";
import FilterIcon from "@mui/icons-material/FilterList";
import ButtonWithStatus from "@src/components/ButtonWithStatus";
import type { TableFilter } from "@src/hooks/useTable";
import type { useFilterInputs } from "./useFilterInputs";
export interface IFiltersPopoverProps {
appliedFilters: TableFilter[];
hasAppliedFilters: boolean;
hasTableFilters: boolean;
tableFiltersOverridden: boolean;
availableFilters: ReturnType<typeof useFilterInputs>["availableFilters"];
setUserFilters: (filters: TableFilter[]) => void;
children: (props: { handleClose: () => void }) => React.ReactNode;
}
export default function FiltersPopover({
appliedFilters,
hasAppliedFilters,
hasTableFilters,
tableFiltersOverridden,
setUserFilters,
availableFilters,
children,
}: IFiltersPopoverProps) {
const anchorEl = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const popoverId = open ? "filters-popover" : undefined;
const handleClose = () => setOpen(false);
return (
<>
<Stack direction="row" style={{ width: "auto" }}>
<ButtonWithStatus
ref={anchorEl}
variant="outlined"
color="primary"
onClick={() => setOpen(true)}
startIcon={<FilterIcon />}
active={hasAppliedFilters}
sx={
hasAppliedFilters
? {
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
position: "relative",
zIndex: 1,
}
: {}
}
aria-describedby={popoverId}
>
{hasAppliedFilters ? "Filtered" : "Filter"}
</ButtonWithStatus>
{appliedFilters.map((filter) => (
<Chip
key={filter.key}
label={`${filter.key} ${filter.operator} ${
availableFilters?.valueFormatter
? availableFilters.valueFormatter(filter.value)
: filter.value
}`}
onDelete={
hasTableFilters && !tableFiltersOverridden
? undefined
: () => setUserFilters([])
}
sx={{
borderRadius: 1,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: "none",
backgroundColor: "background.paper",
height: 32,
"& .MuiChip-label": { px: 1.5 },
}}
variant="outlined"
/>
))}
</Stack>
<Popover
id={popoverId}
open={open}
anchorEl={anchorEl.current}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
sx={{
"& .MuiPaper-root": { width: 640 },
"& .content": { p: 3 },
}}
>
{children({ handleClose })}
</Popover>
</>
);
}

View File

@@ -0,0 +1,434 @@
import { useState, useEffect } from "react";
import _isEmpty from "lodash/isEmpty";
import {
Tab,
Badge,
Button,
Stack,
Divider,
FormControlLabel,
Checkbox,
Alert,
} from "@mui/material";
import TabContext from "@mui/lab/TabContext";
import TabList from "@mui/lab/TabList";
import TabPanel from "@mui/lab/TabPanel";
import FiltersPopover from "./FiltersPopover";
import FilterInputs from "./FilterInputs";
import { useFilterInputs, INITIAL_QUERY } from "./useFilterInputs";
import type { TableFilter } from "@src/hooks/useTable";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { useAppContext } from "@src/contexts/AppContext";
import { DocActions } from "@src/hooks/useDoc";
const shouldDisableApplyButton = (value: any) =>
_isEmpty(value) &&
typeof value !== "boolean" &&
typeof value !== "number" &&
typeof value !== "object";
export default function Filters() {
const { table, tableState, tableActions } = useProjectContext();
const { userDoc, userClaims } = useAppContext();
const tableFilterInputs = useFilterInputs(tableState?.columns || []);
const userFilterInputs = useFilterInputs(tableState?.columns || []);
const { availableFilters } = userFilterInputs;
// Get table filters & user filters from config documents
const tableId = table?.id;
const userDocData = userDoc.state.doc;
const tableSchemaDoc = tableState?.config?.tableConfig?.doc;
const tableFilters = tableSchemaDoc?.filters;
const tableFiltersOverridable = Boolean(tableSchemaDoc?.filtersOverridable);
const userFilters = tableId
? userDocData.tables?.[tableId]?.filters
: undefined;
// Helper booleans
const hasTableFilters =
Array.isArray(tableFilters) && tableFilters.length > 0;
const hasUserFilters = Array.isArray(userFilters) && userFilters.length > 0;
// Set the local table filter
useEffect(() => {
// Set local state for UI
tableFilterInputs.setQuery(
Array.isArray(tableFilters) && tableFilters[0]
? tableFilters[0]
: INITIAL_QUERY
);
userFilterInputs.setQuery(
Array.isArray(userFilters) && userFilters[0]
? userFilters[0]
: INITIAL_QUERY
);
setCanOverrideCheckbox(tableFiltersOverridable);
if (!tableActions) return;
let filtersToApply: TableFilter[] = [];
// Allow override table-level filters with their own
// Set to null to completely ignore table filters
if (tableFiltersOverridable && (hasUserFilters || userFilters === null)) {
filtersToApply = userFilters ?? [];
} else if (hasTableFilters) {
filtersToApply = tableFilters;
} else if (hasUserFilters) {
filtersToApply = userFilters;
}
tableActions.table.filter(filtersToApply);
// Reset order so we dont have to make a new index
tableActions.table.orderBy();
}, [tableFilters, tableFiltersOverridable, userFilters, userClaims?.roles]);
// Helper booleans for local table filter state
const appliedFilters = tableState?.filters || [];
const hasAppliedFilters = Boolean(
appliedFilters && appliedFilters.length > 0
);
const tableFiltersOverridden =
(tableFiltersOverridable || userClaims?.roles.includes("ADMIN")) &&
(hasUserFilters || userFilters === null) &&
hasTableFilters;
// Override table filters
const [canOverrideCheckbox, setCanOverrideCheckbox] = useState(
tableFiltersOverridable
);
const [tab, setTab] = useState<"user" | "table">(
hasTableFilters && !tableFiltersOverridden ? "table" : "user"
);
const [overrideTableFilters, setOverrideTableFilters] = useState(
tableFiltersOverridden
);
// Save table filters to table schema document
const setTableFilters = (filters: TableFilter[]) => {
tableActions?.table.updateConfig("filters", filters);
tableActions?.table.updateConfig("filtersOverridable", canOverrideCheckbox);
};
// Save user filters to user document
// null overrides table filters
const setUserFilters = (filters: TableFilter[] | null) => {
userDoc.dispatch({
action: DocActions.update,
data: {
tables: { [`${tableState?.config.id}`]: { filters } },
},
});
};
return (
<FiltersPopover
appliedFilters={appliedFilters}
hasAppliedFilters={hasAppliedFilters}
hasTableFilters={hasTableFilters}
tableFiltersOverridden={tableFiltersOverridden}
availableFilters={availableFilters}
setUserFilters={setUserFilters}
>
{({ handleClose }) => {
// ADMIN
if (userClaims?.roles.includes("ADMIN")) {
return (
<TabContext value={tab}>
<TabList
onChange={(_, v) => setTab(v)}
variant="fullWidth"
aria-label="Filter tabs"
>
<Tab
label={
<>
Your filter
{tableFiltersOverridden && (
<Badge
aria-label="(overrides table filters)"
color="primary"
variant="inlineDot"
invisible={false}
/>
)}
</>
}
value="user"
style={{ flexDirection: "row" }}
/>
<Tab
label={
<>
Table filter
{tableFiltersOverridden ? (
<Badge
aria-label="(overridden by your filters)"
color="primary"
variant="inlineDot"
invisible={false}
sx={{
"& .MuiBadge-badge": {
bgcolor: "transparent",
border: "1px solid currentColor",
color: "inherit",
},
}}
/>
) : hasTableFilters ? (
<Badge
aria-label="(active)"
color="primary"
variant="inlineDot"
invisible={false}
/>
) : null}
</>
}
value="table"
style={{ flexDirection: "row" }}
/>
</TabList>
<Divider style={{ marginTop: -1 }} />
<TabPanel value="user" className="content">
<FilterInputs {...userFilterInputs} />
{hasTableFilters && (
<FormControlLabel
control={
<Checkbox
checked={overrideTableFilters}
onChange={(e) =>
setOverrideTableFilters(e.target.checked)
}
/>
}
label="Override table filters"
sx={{ justifyContent: "center", mb: 1, mr: 0 }}
/>
)}
<Stack
direction="row"
sx={{ "& .MuiButton-root": { minWidth: 100 } }}
justifyContent="center"
spacing={1}
>
<Button
disabled={
!overrideTableFilters &&
!tableFiltersOverridden &&
userFilterInputs.query.key === ""
}
onClick={() => {
setUserFilters(overrideTableFilters ? null : []);
userFilterInputs.resetQuery();
}}
>
Clear
{hasTableFilters &&
(overrideTableFilters
? " (ignore table filter)"
: " (use table filter)")}
</Button>
<Button
disabled={
(!overrideTableFilters && hasTableFilters) ||
shouldDisableApplyButton(userFilterInputs.query.value)
}
color="primary"
variant="contained"
onClick={() => {
setUserFilters([userFilterInputs.query]);
handleClose();
}}
>
Apply
</Button>
</Stack>
</TabPanel>
<TabPanel value="table" className="content">
<FilterInputs {...tableFilterInputs} />
<FormControlLabel
control={
<Checkbox
checked={canOverrideCheckbox}
onChange={(e) => setCanOverrideCheckbox(e.target.checked)}
/>
}
label="All users can override table filters"
sx={{ justifyContent: "center", mb: 1, mr: 0 }}
/>
<Alert severity="info" style={{ width: "auto" }} sx={{ mb: 3 }}>
<ul style={{ margin: 0, paddingLeft: "1.5em" }}>
<li>
The filter above will be set
{canOverrideCheckbox && " by default"} for all users who
view this table.
</li>
{canOverrideCheckbox ? (
<>
<li>All users can override this.</li>
<li>Only ADMIN users can edit table filters.</li>
</>
) : (
<li>Only ADMIN users can override or edit this.</li>
)}
</ul>
</Alert>
<Stack
direction="row"
sx={{ "& .MuiButton-root": { minWidth: 100 } }}
justifyContent="center"
spacing={1}
>
<Button
disabled={tableFilterInputs.query.key === ""}
onClick={() => {
setTableFilters([]);
tableFilterInputs.resetQuery();
}}
>
Clear
</Button>
<Button
disabled={shouldDisableApplyButton(
tableFilterInputs.query.value
)}
color="primary"
variant="contained"
onClick={() => {
setTableFilters([tableFilterInputs.query]);
handleClose();
}}
>
Apply
</Button>
</Stack>
</TabPanel>
</TabContext>
);
}
// Non-ADMIN, override disabled
if (hasTableFilters && !tableFiltersOverridable) {
return (
<div className="content">
<FilterInputs {...tableFilterInputs} disabled />
<Alert severity="info" style={{ width: "auto" }}>
An ADMIN user has set the filter for this table
</Alert>
</div>
);
}
// Non-ADMIN, override enabled
if (hasTableFilters && tableFiltersOverridable) {
return (
<div className="content">
<FilterInputs {...userFilterInputs} />
<FormControlLabel
control={
<Checkbox
checked={overrideTableFilters}
onChange={(e) => setOverrideTableFilters(e.target.checked)}
/>
}
label="Override table filters"
sx={{ justifyContent: "center", mb: 1, mr: 0 }}
/>
<Stack
direction="row"
sx={{ "& .MuiButton-root": { minWidth: 100 } }}
justifyContent="center"
spacing={1}
>
<Button
disabled={
!overrideTableFilters &&
!tableFiltersOverridden &&
userFilterInputs.query.key === ""
}
onClick={() => {
setUserFilters(overrideTableFilters ? null : []);
userFilterInputs.resetQuery();
}}
>
Clear
{overrideTableFilters
? " (ignore table filter)"
: " (use table filter)"}
</Button>
<Button
disabled={
(!overrideTableFilters && hasTableFilters) ||
shouldDisableApplyButton(userFilterInputs.query.value)
}
color="primary"
variant="contained"
onClick={() => {
setUserFilters([userFilterInputs.query]);
handleClose();
}}
>
Apply
</Button>
</Stack>
</div>
);
}
// Non-ADMIN, no table filters
return (
<div className="content">
<FilterInputs {...userFilterInputs} />
<Stack
direction="row"
sx={{ "& .MuiButton-root": { minWidth: 100 } }}
justifyContent="center"
spacing={1}
>
<Button
disabled={userFilterInputs.query.key === ""}
onClick={() => {
setUserFilters([]);
userFilterInputs.resetQuery();
}}
>
Clear
</Button>
<Button
disabled={shouldDisableApplyButton(
userFilterInputs.query.value
)}
color="primary"
variant="contained"
onClick={() => {
setUserFilters([userFilterInputs.query]);
handleClose();
}}
>
Apply
</Button>
</Stack>
</div>
);
}}
</FiltersPopover>
);
}

View File

@@ -0,0 +1,50 @@
import { useState } from "react";
import _find from "lodash/find";
import _sortBy from "lodash/sortBy";
import { TableState, TableFilter } from "@src/hooks/useTable";
import { getFieldProp } from "@src/components/fields";
export const INITIAL_QUERY = { key: "", operator: "", value: "" };
export const useFilterInputs = (columns: TableState["columns"]) => {
// Get list of columns that can be filtered
const filterColumns = _sortBy(Object.values(columns), "index")
.filter((c) => getFieldProp("filter", c.type))
.map((c) => ({ value: c.key, label: c.name, ...c }));
// State for filter inputs
const [query, setQuery] = useState<TableFilter>(INITIAL_QUERY);
const resetQuery = () => setQuery(INITIAL_QUERY);
// When the user sets a new column, automatically set the operator and value
const handleChangeColumn = (value: string | null) => {
const column = _find(filterColumns, ["key", value]);
if (column) {
const filter = getFieldProp("filter", column.type);
setQuery({
key: column.key,
operator: filter.operators[0].value,
value: filter.defaultValue ?? "",
});
} else {
setQuery(INITIAL_QUERY);
}
};
// Get the column config
const selectedColumn = _find(filterColumns, ["key", query?.key]);
// Get available filters from selected column type
const availableFilters = getFieldProp("filter", selectedColumn?.type);
return {
filterColumns,
selectedColumn,
handleChangeColumn,
availableFilters,
query,
setQuery,
resetQuery,
} as const;
};

View File

@@ -13,6 +13,7 @@ import {
Typography,
TextField,
FormHelperText,
Divider,
} from "@mui/material";
import Tab from "@mui/material/Tab";
@@ -29,6 +30,8 @@ import CheckIcon from "@mui/icons-material/CheckCircle";
import ImportCsvWizard, {
IImportCsvWizardProps,
} from "@src/components/Wizards/ImportCsvWizard";
import { useAppContext } from "@src/contexts/AppContext";
import { useProjectContext } from "@src/contexts/ProjectContext";
const useStyles = makeStyles((theme) =>
createStyles({
@@ -85,6 +88,8 @@ export interface IImportCsvProps {
export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) {
const classes = useStyles();
const { userClaims } = useAppContext();
const { table } = useProjectContext();
const [open, setOpen] = useState<HTMLButtonElement | null>(null);
const [tab, setTab] = useState("upload");
@@ -105,7 +110,7 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) {
const popoverId = open ? "csv-popover" : undefined;
const parseCsv = (csvString: string) =>
parse(csvString, {}, (err, rows) => {
parse(csvString, { delimiter: [",", "\t"] }, (err, rows) => {
if (err) {
setError(err.message);
} else {
@@ -131,7 +136,7 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: false,
accept: "text/csv",
accept: ["text/csv", "text/tab-separated-values"],
});
const [handlePaste] = useDebouncedCallback(
@@ -157,13 +162,15 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) {
const [openWizard, setOpenWizard] = useState(false);
if (table?.readOnly && !userClaims?.roles.includes("ADMIN")) return null;
return (
<>
{render ? (
render(handleOpen)
) : (
<TableHeaderButton
title="Import CSV"
title="Import CSV or TSV"
onClick={handleOpen}
icon={<ImportIcon />}
/>
@@ -201,6 +208,7 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) {
<Tab label="Paste" value="paste" />
<Tab label="URL" value="url" />
</TabList>
<Divider style={{ marginTop: -1 }} />
<TabPanel value="upload" className={classes.tabPanel}>
<Grid
@@ -215,7 +223,7 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) {
<input {...getInputProps()} />
{isDragActive ? (
<Typography variant="button" color="primary">
Drop CSV file here
Drop CSV or TSV file here
</Typography>
) : (
<>
@@ -225,8 +233,8 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) {
<Grid item>
<Typography variant="button" color="inherit">
{validCsv
? "Valid CSV"
: "Click to upload or drop CSV file here"}
? "Valid CSV or TSV"
: "Click to upload or drop CSV or TSV file here"}
</Typography>
</Grid>
</>
@@ -247,7 +255,7 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) {
inputProps={{ minRows: 3 }}
autoFocus
fullWidth
label="Paste CSV text"
label="Paste CSV or TSV text"
placeholder="column, column, …"
onChange={(e) => {
if (csvData !== null) setCsvData(null);
@@ -270,13 +278,13 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) {
variant="filled"
autoFocus
fullWidth
label="Paste URL to CSV file"
label="Paste URL to CSV or TSV file"
placeholder="https://"
onChange={(e) => {
if (csvData !== null) setCsvData(null);
handleUrl(e.target.value);
}}
helperText={loading ? "Fetching CSV…" : error}
helperText={loading ? "Fetching…" : error}
error={!!error}
/>
</TabPanel>

View File

@@ -44,20 +44,36 @@ export const webhookBasic = {
parser: {
additionalVariables,
extraLibs: parserExtraLibs,
template: `const basicParser: Parser = async({req, db,ref}) => {
template: (table) => `const basicParser: Parser = async({req, db,ref}) => {
// request is the request object from the webhook
// db is the database object
// ref is the reference to collection of the table
// the returned object will be added as a new row to the table
// eg: adding the webhook body as row
const {body} = req;
${
table.audit !== false
? `
// auditField
const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.getServiceAccountUser()
return {
...body,
${table.auditFieldCreatedBy ?? "_createdBy"}
}
`
: `
return body;
`
}
}`,
},
condition: {
additionalVariables,
extraLibs: conditionExtraLibs,
template: `const condition: Condition = async({ref,req,db}) => {
template: (table) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
return true;
}`,
@@ -66,7 +82,9 @@ export const webhookBasic = {
return (
<Typography color="text.disabled">
<WarningIcon aria-label="Warning" style={{ verticalAlign: "bottom" }} />
&nbsp; Verification is not currently available for basic webhooks
&nbsp; Specialized verification is not currently available for basic
webhooks, you can add your own verification logic in the conditions
section bellow.
</Typography>
);
},

View File

@@ -6,25 +6,29 @@ export const webhookSendgrid = {
parser: {
additionalVariables: null,
extraLibs: null,
template: `const sendgridParser: Parser = async({req, db,ref}) =>{
// {
// "email": "example@test.com",
// "timestamp": 1513299569,
// "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>",
// "event": "processed",
// "category": "cat facts",
// "sg_event_id": "sg_event_id",
// "sg_message_id": "sg_message_id"
// },
};`,
template: (
table
) => `const sendgridParser: Parser = async ({ req, db, ref }) => {
const { body } = req
const eventHandler = async (sgEvent) => {
// Event handlers can be modiefed to preform different actions based on the sendgrid event
// List of events & docs : https://docs.sendgrid.com/for-developers/tracking-events/event#events
const { event, docPath } = sgEvent
// event param is provided by default
// however docPath or other custom parameter needs be passed in the custom_args variable in Sengrid Extension
return db.doc(docPath).update({ sgStatus: event })
}
//
if (Array.isArray(body)) {
// when multiple events are passed in one call
await Promise.allSettled(body.map(eventHandler))
} else eventHandler(body)
};`,
},
condition: {
additionalVariables: null,
extraLibs: null,
template: `const condition: Condition = async({ref,req,db}) => {
template: (table) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
return true;
}`,

View File

@@ -6,15 +6,16 @@ export const webhookTypeform = {
parser: {
additionalVariables: null,
extraLibs: null,
template: `const typeformParser: Parser = async({req, db,ref}) =>{
template: (
table
) => `const typeformParser: Parser = async({req, db,ref}) =>{
// this reduces the form submission into a single object of key value pairs
// eg: {name: "John", age: 20}
// ⚠️ ensure that you have assigned ref values of the fields
// set the ref value to field key you would like to sync to
// docs: https://help.typeform.com/hc/en-us/articles/360050447552-Block-reference-format-restrictions
const {submitted_at,hidden,answers} = req.body.form_response
return ({
const submission = ({
_createdAt: submitted_at,
...hidden,
...answers.reduce((accRow, currAnswer) => {
@@ -42,12 +43,30 @@ export const webhookTypeform = {
};
}
}, {}),
})};`,
})
${
table.audit !== false
? `
// auditField
const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.getServiceAccountUser()
return {
...submission,
${table.auditFieldCreatedBy ?? "_createdBy"}
}
`
: `
return submission
`
}
};`,
},
condition: {
additionalVariables: null,
extraLibs: null,
template: `const condition: Condition = async({ref,req,db}) => {
template: (table) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
return true;
}`,

View File

@@ -22,10 +22,8 @@ import EmptyState from "@src/components/EmptyState";
import { webhookNames, IWebhook } from "./utils";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import { useProjectContext } from "@src/contexts/ProjectContext";
import {
modalAtom,
cloudLogFiltersAtom,
} from "@src/components/TableHeader/CloudLogs/utils";
import { modalAtom } from "@src/atoms/Table";
import { cloudLogFiltersAtom } from "@src/components/TableHeader/CloudLogs/utils";
export interface IWebhookListProps {
webhooks: IWebhook[];

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { useAtom } from "jotai";
import _isEqual from "lodash/isEqual";
import TableHeaderButton from "../TableHeaderButton";
@@ -16,9 +17,10 @@ import { emptyWebhookObject, IWebhook, WebhookType } from "./utils";
import { runRoutes } from "@src/constants/runRoutes";
import { analytics } from "@src/analytics";
import { useSnackbar } from "notistack";
import { modalAtom } from "@src/atoms/Table";
export default function Webhooks() {
const { tableState, tableActions, rowyRun, compatibleRowyRunVersion } =
const { tableState, table, tableActions, rowyRun, compatibleRowyRunVersion } =
useProjectContext();
const appContext = useAppContext();
const { requestConfirmation } = useConfirmation();
@@ -27,7 +29,11 @@ export default function Webhooks() {
const currentWebhooks = (tableState?.config.webhooks ?? []) as IWebhook[];
const [localWebhooksObjects, setLocalWebhooksObjects] =
useState(currentWebhooks);
const [openWebhookList, setOpenWebhookList] = useState(false);
const [modal, setModal] = useAtom(modalAtom);
const open = modal === "webhooks";
const setOpen = (open: boolean) => setModal(open ? "webhooks" : "");
const [webhookModal, setWebhookModal] = useState<{
mode: "add" | "update";
webhookObject: IWebhook;
@@ -38,58 +44,59 @@ export default function Webhooks() {
const edited = !_isEqual(currentWebhooks, localWebhooksObjects);
const handleOpen = () => {
setOpenWebhookList(true);
};
const handleOpen = () => setOpen(true);
const handleClose = (
setOpen: React.Dispatch<React.SetStateAction<boolean>>
_setOpen: React.Dispatch<React.SetStateAction<boolean>>
) => {
if (edited) {
setOpen(true);
_setOpen(true);
requestConfirmation({
title: "Discard changes?",
confirm: "Discard",
handleConfirm: () => {
setOpen(false);
_setOpen(false);
setLocalWebhooksObjects(currentWebhooks);
setOpenWebhookList(false);
setOpen(false);
},
});
} else {
setOpenWebhookList(false);
setOpen(false);
}
};
const handleSaveWebhooks = async () => {
tableActions?.table.updateConfig("webhooks", localWebhooksObjects);
setOpenWebhookList(false);
const handleSaveWebhooks = async (callback?: Function) => {
tableActions?.table.updateConfig(
"webhooks",
localWebhooksObjects,
callback
);
setOpen(false);
// TODO: convert to async function that awaits for the document write to complete
await new Promise((resolve) => setTimeout(resolve, 500));
};
const handleSaveDeploy = async () => {
await handleSaveWebhooks();
try {
if (rowyRun) {
const resp = await rowyRun({
service: "hooks",
route: runRoutes.publishWebhooks,
body: {
tableConfigPath: tableState?.config.tableConfig.path,
tablePath: tableState?.tablePath,
},
});
enqueueSnackbar(resp.message, {
variant: resp.success ? "success" : "error",
});
analytics.logEvent("published_webhooks");
const handleSaveDeploy = () =>
handleSaveWebhooks(async () => {
try {
if (rowyRun) {
const resp = await rowyRun({
service: "hooks",
route: runRoutes.publishWebhooks,
body: {
tableConfigPath: tableState?.config.tableConfig.path,
tablePath: tableState?.tablePath,
},
});
enqueueSnackbar(resp.message, {
variant: resp.success ? "success" : "error",
});
analytics.logEvent("published_webhooks");
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
};
});
const handleAddWebhook = (webhookObject: IWebhook) => {
setLocalWebhooksObjects([...localWebhooksObjects, webhookObject]);
@@ -169,7 +176,7 @@ export default function Webhooks() {
icon={<WebhookIcon />}
/>
{openWebhookList && !!tableState && (
{open && !!tableState && (
<Modal
onClose={handleClose}
disableBackdropClick={edited}
@@ -182,7 +189,11 @@ export default function Webhooks() {
handleAddWebhook={(type: WebhookType) => {
setWebhookModal({
mode: "add",
webhookObject: emptyWebhookObject(type, currentEditor()),
webhookObject: emptyWebhookObject(
type,
currentEditor(),
table
),
});
}}
variant={
@@ -201,12 +212,16 @@ export default function Webhooks() {
actions={{
primary: {
children: "Save & Deploy",
onClick: handleSaveDeploy,
onClick: () => {
handleSaveDeploy();
},
disabled: !edited,
},
secondary: {
children: "Save",
onClick: handleSaveWebhooks,
onClick: () => {
handleSaveWebhooks();
},
disabled: !edited,
},
}}
@@ -215,9 +230,7 @@ export default function Webhooks() {
{webhookModal && (
<WebhookModal
handleClose={() => {
setWebhookModal(null);
}}
handleClose={() => setWebhookModal(null)}
handleAdd={handleAddWebhook}
handleUpdate={handleUpdateWebhook}
mode={webhookModal.mode}

View File

@@ -76,19 +76,16 @@ export const webhookSchemas = {
export function emptyWebhookObject(
type: WebhookType,
user: IWebhookEditor
user: IWebhookEditor,
table
): IWebhook {
return {
name: "Untitled webhook",
name: `${type} webhook`,
active: false,
endpoint: generateRandomId(),
type,
parser:
webhookSchemas[type].parser?.template ??
webhookSchemas["basic"].parser.template,
conditions:
webhookSchemas[type].condition?.template ??
webhookSchemas["basic"].condition.template,
parser: webhookSchemas[type].parser?.template(table),
conditions: webhookSchemas[type].condition?.template(table),
lastEditor: user,
};
}

View File

@@ -3,13 +3,13 @@ import { Stack } from "@mui/material";
import { isCollectionGroup } from "@src/utils/fns";
import AddRow from "./AddRow";
import Filters from "../Table/Filters";
import Filters from "./Filters";
import ImportCSV from "./ImportCsv";
import Export from "./Export";
import LoadedRowsStatus from "./LoadedRowsStatus";
import TableSettings from "./TableSettings";
import CloudLogs from "./CloudLogs";
import HiddenFields from "../Table/HiddenFields";
import HiddenFields from "./HiddenFields";
import RowHeight from "./RowHeight";
import Extensions from "./Extensions";
import Webhooks from "./Webhooks";

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import { Control, useWatch } from "react-hook-form";
import stringify from "json-stable-stringify-without-jsonify";
import _isEmpty from "lodash/isEmpty";
import { useSnackbar } from "notistack";
import { MenuItem, DialogContentText, LinearProgress } from "@mui/material";
import Modal from "@src/components/Modal";
import CodeEditor from "@src/components/CodeEditor";
import useTableConfig from "@src/hooks/useTable/useTableConfig";
export interface IExportSettingsProps {
closeMenu: () => void;
control: Control;
}
export default function ExportSettings({
closeMenu,
control,
}: IExportSettingsProps) {
const [open, setOpen] = useState(false);
const { _suggestedRules, ...values } = useWatch({ control });
const [tableConfigState] = useTableConfig(values.id);
const { id, ref, ..._schema } = tableConfigState.doc ?? {};
const formattedJson = stringify(
// Allow values._schema to take priority if user imported _schema before
"_schema" in values || _isEmpty(_schema) ? values : { ...values, _schema },
{
space: 2,
cmp: (a, b) =>
// Sort _schema at the end
a.key.startsWith("_")
? 1
: // Otherwise, sort alphabetically
a.key > b.key
? 1
: -1,
}
);
const handleClose = () => {
setOpen(false);
closeMenu();
};
const { enqueueSnackbar } = useSnackbar();
const handleExport = () => {
navigator.clipboard.writeText(formattedJson);
enqueueSnackbar("Copied to clipboard");
handleClose();
};
return (
<>
<MenuItem onClick={() => setOpen(true)}>Export table settings</MenuItem>
{open && (
<Modal
onClose={handleClose}
title="Export table settings"
header={
<>
{tableConfigState.loading && values.id && (
<LinearProgress
style={{ position: "absolute", top: 0, left: 0, right: 0 }}
/>
)}
<DialogContentText style={{ margin: "0 var(--dialog-spacing)" }}>
Export table settings and columns in JSON format
</DialogContentText>
</>
}
body={
<div style={{ marginTop: "var(--dialog-contents-spacing)" }}>
<CodeEditor
disabled
value={formattedJson}
defaultLanguage="json"
minHeight={300}
/>
</div>
}
actions={{
primary: {
children: "Copy to clipboard",
onClick: handleExport,
},
secondary: {
children: "Cancel",
onClick: handleClose,
},
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,177 @@
import { useState } from "react";
import { Control, useWatch } from "react-hook-form";
import type { UseFormReturn, FieldValues } from "react-hook-form";
import stringify from "json-stable-stringify-without-jsonify";
import _isEmpty from "lodash/isEmpty";
import _get from "lodash/get";
import { useSnackbar } from "notistack";
import { MenuItem, DialogContentText, FormHelperText } from "@mui/material";
import Modal from "@src/components/Modal";
import DiffEditor from "@src/components/CodeEditor/DiffEditor";
import useTableConfig from "@src/hooks/useTable/useTableConfig";
import { useConfirmation } from "@src/components/ConfirmationDialog";
export interface IImportSettingsProps {
closeMenu: () => void;
control: Control;
useFormMethods: UseFormReturn<FieldValues, object>;
}
export default function ImportSettings({
closeMenu,
control,
useFormMethods,
}: IImportSettingsProps) {
const [open, setOpen] = useState(false);
const [newSettings, setNewSettings] = useState("");
const [valid, setValid] = useState(true);
const { _suggestedRules, ...values } = useWatch({ control });
const [tableConfigState] = useTableConfig(values.id);
const { id, ref, ..._schema } = tableConfigState.doc ?? {};
const formattedJson = stringify(
// Allow values._schema to take priority if user imported _schema before
"_schema" in values || _isEmpty(_schema) ? values : { ...values, _schema },
{
space: 2,
cmp: (a, b) =>
// Sort _schema at the end
a.key.startsWith("_")
? 1
: // Otherwise, sort alphabetically
a.key > b.key
? 1
: -1,
}
);
const handleClose = () => {
setOpen(false);
closeMenu();
};
const { requestConfirmation } = useConfirmation();
const { enqueueSnackbar } = useSnackbar();
const { setValue } = useFormMethods;
const handleImport = () => {
const { id, collection, ...newValues } = JSON.parse(newSettings);
for (const key in newValues) {
setValue(key, newValues[key], {
shouldDirty: true,
shouldValidate: true,
});
}
enqueueSnackbar("Imported settings");
handleClose();
};
return (
<>
<MenuItem onClick={() => setOpen(true)}>Import table settings</MenuItem>
{open && (
<Modal
onClose={handleClose}
title="Import table settings"
header={
<DialogContentText style={{ margin: "0 var(--dialog-spacing)" }}>
Import table settings in JSON format. This will overwrite any
existing settings, except for the table ID and collection.
</DialogContentText>
}
body={
<div style={{ marginTop: "var(--dialog-contents-spacing)" }}>
<DiffEditor
original={formattedJson}
modified={newSettings}
language="json"
onChange={(v) => {
try {
if (v) {
JSON.parse(v);
setNewSettings(v);
setValid(true);
}
} catch (e) {
console.log(`Failed to parse JSON: ${e}`);
setValid(false);
}
}}
error={!valid}
minHeight={300}
/>
</div>
}
footer={
!valid && (
<FormHelperText
error
variant="filled"
sx={{ mx: "auto", mt: 1, mb: -1 }}
>
Invalid JSON
</FormHelperText>
)
}
actions={{
primary: {
children: "Import",
onClick: () => {
const parsedJson = JSON.parse(newSettings);
const hasExtensions = Boolean(
_get(parsedJson, "_schema.extensionObjects")
);
const hasWebhooks = Boolean(
_get(parsedJson, "_schema.webhooks")
);
requestConfirmation({
title: "Import settings?",
customBody: (
<>
<DialogContentText paragraph>
You will overwrite any existing settings for this table,{" "}
<b>except for the table ID and collection</b>.
</DialogContentText>
{(hasExtensions || hasWebhooks) && (
<DialogContentText paragraph>
Youre importing new{" "}
<b>
{[
hasExtensions && "extensions",
hasWebhooks && "webhooks",
]
.filter(Boolean)
.join(" and ")}
</b>{" "}
for this table. Youll be prompted to <b>deploy</b>{" "}
them when you save the table settings.
</DialogContentText>
)}
</>
),
confirm: "Import",
handleConfirm: handleImport,
});
},
disabled: !valid,
},
secondary: {
children: "Cancel",
onClick: handleClose,
},
}}
maxWidth="lg"
/>
)}
</>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from "react";
import { Control } from "react-hook-form";
import type { UseFormReturn, FieldValues } from "react-hook-form";
import { IconButton, Menu } from "@mui/material";
import ExportIcon from "assets/icons/Export";
import ImportIcon from "assets/icons/Import";
import { TableSettingsDialogModes } from "../index";
import ImportSettings from "./ImportSettings";
import ExportSettings from "./ExportSettings";
export interface IActionsMenuProps {
mode: TableSettingsDialogModes | null;
control: Control;
useFormMethods: UseFormReturn<FieldValues, object>;
}
export default function ActionsMenu({
mode,
control,
useFormMethods,
}: IActionsMenuProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClose = () => setAnchorEl(null);
return (
<>
<IconButton
aria-label="Actions…"
id="table-settings-actions-button"
aria-controls="table-settings-actions-menu"
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
{mode === TableSettingsDialogModes.create ? (
<ImportIcon />
) : (
<ExportIcon />
)}
</IconButton>
<Menu
id="table-settings-actions-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{ "aria-labelledby": "table-settings-actions-button" }}
disablePortal
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<ImportSettings
closeMenu={handleClose}
control={control}
useFormMethods={useFormMethods}
/>
<ExportSettings closeMenu={handleClose} control={control} />
</Menu>
</>
);
}

View File

@@ -0,0 +1,129 @@
import { useState } from "react";
import { useHistory } from "react-router-dom";
import { IconButton, Menu, MenuItem, DialogContentText } from "@mui/material";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import Confirmation from "@src/components/Confirmation";
import { Table } from "@src/contexts/ProjectContext";
import { routes } from "@src/constants/routes";
import { db } from "@src/firebase";
import { name } from "@root/package.json";
import {
SETTINGS,
TABLE_SCHEMAS,
TABLE_GROUP_SCHEMAS,
} from "@src/config/dbPaths";
export interface IDeleteMenuProps {
clearDialog: () => void;
data: Table | null;
}
export default function DeleteMenu({ clearDialog, data }: IDeleteMenuProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClose = () => setAnchorEl(null);
const history = useHistory();
const handleResetStructure = async () => {
const schemaDocRef = db.doc(`${TABLE_SCHEMAS}/${data!.id}`);
await schemaDocRef.update({ columns: {} });
clearDialog();
};
const handleDelete = async () => {
const tablesDocRef = db.doc(SETTINGS);
const tableData = (await tablesDocRef.get()).data();
const updatedTables = tableData?.tables.filter(
(table) => table.id !== data?.id || table.tableType !== data?.tableType
);
tablesDocRef.update({ tables: updatedTables });
db.collection(
data?.tableType === "primaryCollection"
? TABLE_SCHEMAS
: TABLE_GROUP_SCHEMAS
)
.doc(data?.id)
.delete();
clearDialog();
history.push(routes.home);
};
return (
<>
<IconButton
aria-label="Delete table…"
id="table-settings-delete-button"
aria-controls="table-settings-delete-menu"
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
<DeleteIcon />
</IconButton>
<Menu
id="table-settings-delete-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{ "aria-labelledby": "table-settings-delete-button" }}
disablePortal
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<Confirmation
message={{
title: `Reset columns of “${data?.name}”?`,
body: (
<>
<DialogContentText paragraph>
This will only reset the columns of this column so you can set
up the columns again.
</DialogContentText>
<DialogContentText>
You will not lose any data in your Firestore collection{" "}
<code>{data?.collection}</code>.
</DialogContentText>
</>
),
confirm: "Reset",
color: "error",
}}
functionName="onClick"
>
<MenuItem onClick={handleResetStructure} color="error">
Reset columns
</MenuItem>
</Confirmation>
<Confirmation
message={{
title: `Delete the table “${data?.name}”?`,
body: (
<>
<DialogContentText paragraph>
This will only delete the {name} configuration data.
</DialogContentText>
<DialogContentText>
You will not lose any data in your Firestore collection{" "}
<code>{data?.collection}</code>.
</DialogContentText>
</>
),
confirm: "Delete",
color: "error",
}}
functionName="onClick"
>
<MenuItem color="error" onClick={handleDelete}>
Delete table
</MenuItem>
</Confirmation>
</Menu>
</>
);
}

View File

@@ -24,8 +24,11 @@ export default function SuggestedRules({
}: ISuggestedRulesProps) {
const { projectId } = useAppContext();
const watched = useWatch({ control, name: ["collection", "roles"] } as any);
const [collection, roles] = Array.isArray(watched) ? watched : [];
const watched = useWatch({
control,
name: ["collection", "roles", "readOnly"],
} as any);
const [collection, roles, readOnly] = Array.isArray(watched) ? watched : [];
const [customized, setCustomized] = useState<boolean>(false);
const [customizations, setCustomizations] = useState<customizationOptions[]>(
@@ -44,7 +47,9 @@ export default function SuggestedRules({
const generatedRules = `match /${collection}/{${
customizations.includes("subcollections") ? "document=**" : "docId"
}} {
allow read, write: if hasAnyRole(${JSON.stringify(roles)});${
allow read, write: if hasAnyRole(${
readOnly ? `["ADMIN"]` : JSON.stringify(roles)
});${
customizations.includes("allRead")
? "\n allow read: if true;"
: customizations.includes("authRead")

View File

@@ -52,6 +52,7 @@ export default function CamelCaseId({
</>
)
}
FormHelperTextProps={{ component: "div" } as any}
name={name}
id={`field-${name}`}
sx={{ "& .MuiInputBase-input": { fontFamily: "mono" } }}

View File

@@ -1,3 +1,4 @@
import _find from "lodash/find";
import { Field, FieldType } from "@rowy/form-builder";
import { TableSettingsDialogModes } from "./index";
@@ -8,6 +9,7 @@ import WarningIcon from "@mui/icons-material/WarningAmber";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import { name } from "@root/package.json";
import { FieldType as TableFieldType } from "@src/constants/fields";
import InputAdornment from "@mui/material/InputAdornment";
export const tableSettings = (
mode: TableSettingsDialogModes | null,
@@ -19,28 +21,76 @@ export const tableSettings = (
collections: string[]
): Field[] =>
[
// Step 1: Collection
{
type: FieldType.shortText,
name: "name",
label: "Table name",
step: "collection",
type: FieldType.singleSelect,
name: "tableType",
label: "Table type",
defaultValue: "primaryCollection",
options: [
{
label: (
<div>
Primary collection
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{
width: 470,
whiteSpace: "normal",
".MuiSelect-select &": { display: "none" },
}}
>
Connect this table to the <b>single collection</b> matching the
collection name entered below
</Typography>
</div>
),
value: "primaryCollection",
},
{
label: (
<div>
Collection group
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{
width: 470,
whiteSpace: "normal",
".MuiSelect-select &": { display: "none" },
}}
>
Connect this table to <b>all collections and subcollections</b>{" "}
matching the collection name entered below
</Typography>
</div>
),
value: "collectionGroup",
},
],
required: true,
assistiveText: "User-facing name for this table",
autoFocus: true,
gridCols: { xs: 12, sm: 6 },
},
{
type: "camelCaseId",
name: "id",
label: "Table ID",
required: true,
watchedField: "name",
assistiveText: `Unique ID for this table used to store configuration. Cannot be edited${
mode === TableSettingsDialogModes.create ? " later" : ""
}.`,
disabled: mode === TableSettingsDialogModes.update,
gridCols: { xs: 12, sm: 6 },
assistiveText: (
<>
Cannot be edited
{mode === TableSettingsDialogModes.create && " later"}.{" "}
<Link
href="https://firebase.googleblog.com/2019/06/understanding-collection-group-queries.html"
target="_blank"
rel="noopener noreferrer"
>
Learn more about collection groups
<OpenInNewIcon />
</Link>
</>
),
},
{
step: "collection",
type: FieldType.singleSelect,
name: "collection",
label: "Collection",
@@ -58,8 +108,8 @@ export const tableSettings = (
aria-label="Warning"
sx={{ fontSize: 16, mr: 0.5, verticalAlign: "middle" }}
/>
You change which Firestore collection to display. Data in the new
collection must be compatible with the existing columns.
You can change which Firestore collection to display. Data in the
new collection must be compatible with the existing columns.
</>
) : (
"Choose which Firestore collection to display."
@@ -74,14 +124,17 @@ export const tableSettings = (
</Link>
</>
),
AddButtonProps: { children: "Add collection…" },
AddButtonProps: {
children: "Create collection or use custom path…",
},
AddDialogProps: {
title: "Add collection",
title: "Create collection or use custom path",
textFieldLabel: (
<>
Collection name
<Typography variant="caption" display="block">
(Collection wont be created until you add a row)
If this collection does not exist, it wont be created until you
add a row to the table
</Typography>
</>
),
@@ -89,83 +142,56 @@ export const tableSettings = (
TextFieldProps: {
sx: { "& .MuiInputBase-input": { fontFamily: "mono" } },
},
gridCols: { xs: 12, sm: 6 },
},
{
type: FieldType.singleSelect,
name: "tableType",
label: "Table type",
defaultValue: "primaryCollection",
options: [
{
label: (
<div>
Primary collection
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{
width: 240,
whiteSpace: "normal",
".MuiSelect-select &": { display: "none" },
}}
>
Connect this table to the <b>single collection</b> matching the
collection name entered above
</Typography>
</div>
),
value: "primaryCollection",
},
{
label: (
<div>
Collection group
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{
width: 240,
whiteSpace: "normal",
".MuiSelect-select &": { display: "none" },
}}
>
Connect this table to <b>all collections and subcollections</b>{" "}
matching the collection name entered above
</Typography>
</div>
),
value: "collectionGroup",
},
// https://firebase.google.com/docs/firestore/quotas#collections_documents_and_fields
validation: [
["matches", /^[^\s]+$/, "Collection name cannot have spaces"],
["notOneOf", [".", ".."], "Collection name cannot be . or .."],
[
"test",
"double-underscore",
"Collection name cannot begin and end with __",
(value) => !value.startsWith("__") && !value.endsWith("__"),
],
],
required: true,
disabled: mode === TableSettingsDialogModes.update,
assistiveText: (
<>
Cannot be edited
{mode === TableSettingsDialogModes.create && " later"}.{" "}
<Link
href="https://firebase.googleblog.com/2019/06/understanding-collection-group-queries.html"
target="_blank"
rel="noopener noreferrer"
display="block"
>
Learn more about collection groups
<OpenInNewIcon />
</Link>
</>
),
gridCols: { xs: 12, sm: 6 },
},
// Step 2: Display
{
type: FieldType.contentHeader,
name: "_contentHeader_userFacing",
label: "Display",
step: "display",
type: FieldType.shortText,
name: "name",
label: "Table name",
required: true,
assistiveText: "User-facing name for this table",
autoFocus: true,
gridCols: { xs: 12, sm: 6 },
},
{
step: "display",
type: "tableId",
name: "id",
label: "Table ID",
required: true,
watchedField: "name",
assistiveText: `Unique ID used to store this tables configuration. Cannot be edited${
mode === TableSettingsDialogModes.create ? " later" : ""
}.`,
disabled: mode === TableSettingsDialogModes.update,
gridCols: { xs: 12, sm: 6 },
validation:
mode === TableSettingsDialogModes.create
? [
[
"test",
"unique",
"Another table exists with this ID",
(value) => !_find(tables, ["value", value]),
],
]
: [],
},
{
step: "display",
type: FieldType.singleSelect,
name: "section",
label: "Section (optional)",
@@ -173,22 +199,18 @@ export const tableSettings = (
freeText: true,
options: sections,
required: false,
gridCols: { xs: 12, sm: 6 },
},
{
step: "display",
type: FieldType.paragraph,
name: "description",
label: "Description (optional)",
gridCols: { xs: 12, sm: 6 },
minRows: 1,
minRows: 2,
},
// Step 3: Access controls
{
type: FieldType.contentHeader,
name: "_contentHeader_admin",
label: "Access controls",
},
{
step: "accessControls",
type: FieldType.multiSelect,
name: "roles",
label: "Accessed by",
@@ -199,6 +221,16 @@ export const tableSettings = (
freeText: true,
},
{
step: "accessControls",
type: FieldType.checkbox,
name: "readOnly",
label: "Read-only for non-ADMIN users",
assistiveText:
"Disable all editing functionality. Locks all columns and disables adding and deleting rows and columns.",
defaultValue: false,
},
{
step: "accessControls",
type: FieldType.contentParagraph,
name: "_contentParagraph_rules",
label: (
@@ -218,12 +250,47 @@ export const tableSettings = (
),
},
{
step: "accessControls",
type: "suggestedRules",
name: "_suggestedRules",
label: "Suggested Firestore Rules",
watchedField: "collection",
},
// Step 4: Auditing
{
step: "auditing",
type: FieldType.checkbox,
name: "audit",
label: "Enable auditing for this table",
defaultValue: true,
},
{
step: "auditing",
type: FieldType.shortText,
name: "auditFieldCreatedBy",
label: "Created By field key (optional)",
defaultValue: "_createdBy",
displayCondition: "return values.audit",
assistiveText: "Optionally, change the field key",
gridCols: { xs: 12, sm: 6 },
sx: { "& .MuiInputBase-input": { fontFamily: "mono" } },
},
{
step: "auditing",
type: FieldType.shortText,
name: "auditFieldUpdatedBy",
label: "Updated By field key (optional)",
defaultValue: "_updatedBy",
displayCondition: "return values.audit",
assistiveText: "Optionally, change the field key",
gridCols: { xs: 12, sm: 6 },
sx: { "& .MuiInputBase-input": { fontFamily: "mono" } },
},
// Step 5:Cloud functions
/*
{
step: "function",
type: FieldType.slider,
name: "triggerDepth",
defaultValue: 1,
@@ -257,47 +324,72 @@ export const tableSettings = (
},
{
type: FieldType.contentHeader,
name: "_contentHeader_audit",
label: "Auditing",
},
{
type: FieldType.checkbox,
name: "audit",
label: "Enable auditing for this table",
defaultValue: true,
assistiveText: "Track when users create or update rows",
},
{
type: FieldType.shortText,
name: "auditFieldCreatedBy",
label: "Created By field key (optional)",
defaultValue: "_createdBy",
displayCondition: "return values.audit",
assistiveText: "Optionally change the field key",
step: "function",
type: FieldType.singleSelect,
name: "function.memory",
label: "Memory Allocation",
defaultValue: "256MB",
options: ["128MB", "256MB", "512MB", "1GB", "2GB", "4GB", "8GB"],
required: true,
gridCols: { xs: 12, sm: 6 },
sx: { "& .MuiInputBase-input": { fontFamily: "mono" } },
},
{
step: "function",
type: FieldType.shortText,
name: "auditFieldUpdatedBy",
label: "Updated By field key (optional)",
defaultValue: "_updatedBy",
displayCondition: "return values.audit",
assistiveText: "Optionally change the field key",
name: "function.timeout",
label: "Timeout",
defaultValue: 60,
InputProps: {
type: "number",
endAdornment: <InputAdornment position="end">seconds</InputAdornment>,
},
gridCols: { xs: 12, sm: 6 },
sx: { "& .MuiInputBase-input": { fontFamily: "mono" } },
},
{
step: "function",
type: FieldType.contentSubHeader,
name: "functionHeader",
label: "Auto scaling",
mode === TableSettingsDialogModes.create
? {
type: FieldType.contentHeader,
name: "_contentHeader_columns",
label: "Columns",
}
: null,
assistiveText: (
<>
<Link
href="https://firebase.google.com/docs/functions/autoscaling"
target="_blank"
rel="noopener noreferrer"
>
Learn more about auto scaling
<OpenInNewIcon />
</Link>
</>
),
},
{
step: "function",
type: FieldType.shortText,
name: "function.minInstances",
label: "Minimum Instances",
defaultValue: 0,
InputProps: {
type: "number",
},
gridCols: { xs: 12, sm: 6 },
},
{
step: "function",
type: FieldType.shortText,
name: "function.maxInstances",
label: "Maximum Instances",
defaultValue: 1000,
InputProps: {
type: "number",
},
gridCols: { xs: 12, sm: 6 },
},
*/
mode === TableSettingsDialogModes.create && tables && tables?.length !== 0
? {
step: "columns",
type: FieldType.singleSelect,
name: "schemaSource",
label: "Copy columns from existing table (optional)",
@@ -320,6 +412,7 @@ export const tableSettings = (
: null,
mode === TableSettingsDialogModes.create
? {
step: "columns",
type: FieldType.contentSubHeader,
name: "_contentSubHeader_initialColumns",
label: "Initial columns",
@@ -328,6 +421,7 @@ export const tableSettings = (
: null,
mode === TableSettingsDialogModes.create
? {
step: "columns",
type: FieldType.checkbox,
name: `_initialColumns.${TableFieldType.createdBy}`,
label: "Created By",
@@ -338,6 +432,7 @@ export const tableSettings = (
: null,
mode === TableSettingsDialogModes.create
? {
step: "columns",
type: FieldType.checkbox,
name: `_initialColumns.${TableFieldType.updatedBy}`,
label: "Updated By",
@@ -348,6 +443,7 @@ export const tableSettings = (
: null,
mode === TableSettingsDialogModes.create
? {
step: "columns",
type: FieldType.checkbox,
name: `_initialColumns.${TableFieldType.createdAt}`,
label: "Created At",
@@ -358,6 +454,7 @@ export const tableSettings = (
: null,
mode === TableSettingsDialogModes.create
? {
step: "columns",
type: FieldType.checkbox,
name: `_initialColumns.${TableFieldType.updatedAt}`,
label: "Updated At",
@@ -368,6 +465,7 @@ export const tableSettings = (
: null,
mode === TableSettingsDialogModes.create
? {
step: "columns",
type: FieldType.checkbox,
name: `_initialColumns.${TableFieldType.id}`,
label: "Row ID",

View File

@@ -1,27 +1,30 @@
import useSWR from "swr";
import _find from "lodash/find";
import _sortBy from "lodash/sortBy";
import _get from "lodash/get";
import { useSnackbar } from "notistack";
import { Stack, Button, DialogContentText } from "@mui/material";
import { DialogContentText, Stack, Typography } from "@mui/material";
import { FormDialog } from "@rowy/form-builder";
import { FormDialog, FormFields } from "@rowy/form-builder";
import { tableSettings } from "./form";
import CamelCaseId from "./CamelCaseId";
import TableId from "./TableId";
import SuggestedRules from "./SuggestedRules";
import Confirmation from "@src/components/Confirmation";
import SteppedAccordion from "@src/components/SteppedAccordion";
import ActionsMenu from "./ActionsMenu";
import DeleteMenu from "./DeleteMenu";
import { useProjectContext, Table } from "@src/contexts/ProjectContext";
import useRouter from "@src/hooks/useRouter";
import { routes } from "@src/constants/routes";
import { db } from "@src/firebase";
import { name } from "@root/package.json";
import {
SETTINGS,
TABLE_SCHEMAS,
TABLE_GROUP_SCHEMAS,
} from "@src/config/dbPaths";
import { useConfirmation } from "@src/components/ConfirmationDialog";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import { runRoutes } from "@src/constants/runRoutes";
import { analytics } from "@src/analytics";
import {
CONFIG,
TABLE_GROUP_SCHEMAS,
TABLE_SCHEMAS,
} from "@src/config/dbPaths";
export enum TableSettingsDialogModes {
create,
@@ -33,7 +36,7 @@ export interface ICreateTableDialogProps {
data: Table | null;
}
export default function TableSettingsDialog({
export default function TableSettings({
mode,
clearDialog,
data,
@@ -44,6 +47,9 @@ export default function TableSettingsDialog({
);
const router = useRouter();
const { requestConfirmation } = useConfirmation();
const snackLogContext = useSnackLogContext();
const { enqueueSnackbar } = useSnackbar();
const { data: collections } = useSWR(
"firebaseCollections",
@@ -57,57 +63,124 @@ export default function TableSettingsDialog({
const handleSubmit = async (v) => {
const { _suggestedRules, ...values } = v;
const data: any = { ...values };
const data = { ...values };
if (values.schemaSource)
data.schemaSource = _find(tables, { id: values.schemaSource });
const hasExtensions = Boolean(_get(data, "_schema.extensionObjects"));
const hasWebhooks = Boolean(_get(data, "_schema.webhooks"));
const deployExtensionsWebhooks = (onComplete?: () => void) => {
if (rowyRun && (hasExtensions || hasWebhooks)) {
requestConfirmation({
title: `Deploy ${[
hasExtensions && "extensions",
hasWebhooks && "webhooks",
]
.filter(Boolean)
.join(" and ")}?`,
body: "You can also deploy later from the table page",
confirm: "Deploy",
cancel: "Later",
handleConfirm: async () => {
const tablePath = data.collection;
const tableConfigPath = `${
data.tableType !== "collectionGroup"
? TABLE_SCHEMAS
: TABLE_GROUP_SCHEMAS
}/${data.id}`;
if (hasExtensions) {
// find derivative, default value
snackLogContext.requestSnackLog();
rowyRun({
route: runRoutes.buildFunction,
body: {
tablePath,
pathname: `/${
data.tableType === "collectionGroup"
? "tableGroup"
: "table"
}/${data.id}`,
tableConfigPath,
},
});
analytics.logEvent("deployed_extensions");
}
if (hasWebhooks) {
const resp = await rowyRun({
service: "hooks",
route: runRoutes.publishWebhooks,
body: {
tableConfigPath,
tablePath,
},
});
enqueueSnackbar(resp.message, {
variant: resp.success ? "success" : "error",
});
analytics.logEvent("published_webhooks");
}
if (onComplete) onComplete();
},
});
} else {
if (onComplete) onComplete();
}
};
if (mode === TableSettingsDialogModes.update) {
await settingsActions?.updateTable(data);
deployExtensionsWebhooks();
clearDialog();
} else {
settingsActions?.createTable(data);
if (router.location.pathname === "/") {
router.history.push(
`${values.tableType === "collectionGroup" ? "tableGroup" : "table"}/${
values.id
}`
);
} else {
router.history.push(values.id);
}
await settingsActions?.createTable(data);
deployExtensionsWebhooks(() => {
if (router.location.pathname === "/") {
router.history.push(
`${
values.tableType === "collectionGroup" ? "tableGroup" : "table"
}/${values.id}`
);
} else {
router.history.push(values.id);
}
clearDialog();
});
}
analytics.logEvent(
TableSettingsDialogModes.update ? "update_table" : "create_table",
{ type: values.tableType }
);
clearDialog();
};
const handleResetStructure = async () => {
const schemaDocRef = db.doc(`${TABLE_SCHEMAS}/${data!.id}`);
await schemaDocRef.update({ columns: {} });
clearDialog();
};
const handleDelete = async () => {
const tablesDocRef = db.doc(SETTINGS);
const tableData = (await tablesDocRef.get()).data();
const updatedTables = tableData?.tables.filter(
(table) => table.id !== data?.id || table.tableType !== data?.tableType
);
await tablesDocRef.update({ tables: updatedTables });
await db
.collection(
data?.tableType === "primaryCollection"
? TABLE_SCHEMAS
: TABLE_GROUP_SCHEMAS
)
.doc(data?.id)
.delete();
clearDialog();
router.history.push(routes.home);
const fields = tableSettings(
mode,
roles,
sectionNames,
_sortBy(
tables?.map((table) => ({
label: table.name,
value: table.id,
section: table.section,
collection: table.collection,
})),
["section", "label"]
),
Array.isArray(collections) ? collections.filter((x) => x !== CONFIG) : []
);
const customComponents = {
tableId: {
component: TableId,
defaultValue: "",
validation: [["string"]],
},
suggestedRules: {
component: SuggestedRules,
defaultValue: "",
validation: [["string"]],
},
};
return (
@@ -118,108 +191,207 @@ export default function TableSettingsDialog({
? "Create table"
: "Table settings"
}
fields={tableSettings(
mode,
roles,
sectionNames,
_sortBy(
tables?.map((table) => ({
label: table.name,
value: table.id,
section: table.section,
collection: table.collection,
})),
["section", "label"]
),
Array.isArray(collections) ? collections : []
)}
customComponents={{
camelCaseId: {
component: CamelCaseId,
defaultValue: "",
validation: [["string"]],
},
suggestedRules: {
component: SuggestedRules,
defaultValue: "",
validation: [["string"]],
},
fields={fields}
customBody={(formFieldsProps) => {
const { errors } = formFieldsProps.useFormMethods.formState;
const groupedErrors: Record<string, string> = Object.entries(
errors
).reduce((acc, [name, err]) => {
const match = _find(fields, ["name", name])?.step;
if (!match) return acc;
acc[match] = err.message;
return acc;
}, {});
return (
<>
<Stack
direction="row"
spacing={1}
sx={{
display: "flex",
height: "var(--dialog-title-height)",
alignItems: "center",
position: "absolute",
top: 0,
right: 40 + 12 + 8,
}}
>
<ActionsMenu
mode={mode}
control={formFieldsProps.control}
useFormMethods={formFieldsProps.useFormMethods}
/>
{mode === TableSettingsDialogModes.update && (
<DeleteMenu clearDialog={clearDialog} data={data} />
)}
</Stack>
<SteppedAccordion
disableUnmount
steps={
[
{
id: "collection",
title: "Collection",
content: (
<>
<DialogContentText paragraph>
Connect this table to a new or existing Firestore
collection
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter((f) => f.step === "collection")}
/>
</>
),
optional: false,
error: Boolean(groupedErrors.collection),
subtitle: groupedErrors.collection && (
<Typography variant="caption" color="error">
{groupedErrors.collection}
</Typography>
),
},
{
id: "display",
title: "Display",
content: (
<>
<DialogContentText paragraph>
Set how this table is displayed to users
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter((f) => f.step === "display")}
customComponents={customComponents}
/>
</>
),
optional: false,
error: Boolean(groupedErrors.display),
subtitle: groupedErrors.display && (
<Typography variant="caption" color="error">
{groupedErrors.display}
</Typography>
),
},
{
id: "accessControls",
title: "Access controls",
content: (
<>
<DialogContentText paragraph>
Set who can view and edit this table. Only ADMIN users
can edit table settings or add, edit, and delete
columns.
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter(
(f) => f.step === "accessControls"
)}
customComponents={customComponents}
/>
</>
),
optional: false,
error: Boolean(groupedErrors.accessControls),
subtitle: groupedErrors.accessControls && (
<Typography variant="caption" color="error">
{groupedErrors.accessControls}
</Typography>
),
},
{
id: "auditing",
title: "Auditing",
content: (
<>
<DialogContentText paragraph>
Track when users create or update rows
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter((f) => f.step === "auditing")}
/>
</>
),
optional: true,
error: Boolean(groupedErrors.auditing),
subtitle: groupedErrors.auditing && (
<Typography variant="caption" color="error">
{groupedErrors.auditing}
</Typography>
),
},
/**
* TODO: Figure out where to store this settings
{
id: "function",
title: "Cloud Function",
content: (
<>
<DialogContentText paragraph>
Configure cloud function settings, this setting is shared across all tables connected to the same collection
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter((f) => f.step === "function")}
/>
</>
),
optional: true,
error: Boolean(groupedErrors.function),
subtitle: groupedErrors.auditing && (
<Typography variant="caption" color="error">
{groupedErrors.function}
</Typography>
),
},
*/
mode === TableSettingsDialogModes.create
? {
id: "columns",
title: "Columns",
content: (
<>
<DialogContentText paragraph>
Initialize table with columns
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter(
(f) => f.step === "columns"
)}
/>
</>
),
optional: true,
error: Boolean(groupedErrors.columns),
subtitle: groupedErrors.columns && (
<Typography variant="caption" color="error">
{groupedErrors.columns}
</Typography>
),
}
: null,
].filter(Boolean) as any
}
/>
</>
);
}}
customComponents={customComponents}
values={{ ...data }}
onSubmit={handleSubmit}
SubmitButtonProps={{
children:
mode === TableSettingsDialogModes.create ? "Create" : "Update",
}}
formFooter={
mode === TableSettingsDialogModes.update ? (
<Stack
direction="row"
justifyContent="center"
spacing={1}
sx={{ mt: 6 }}
>
<Confirmation
message={{
title: `Reset columns of “${data?.name}”?`,
body: (
<>
<DialogContentText paragraph>
This will only reset the columns of this column so you can
set up the columns again.
</DialogContentText>
<DialogContentText>
You will not lose any data in your Firestore collection{" "}
<code>{data?.collection}</code>.
</DialogContentText>
</>
),
confirm: "Reset",
color: "error",
}}
functionName="onClick"
>
<Button
variant="outlined"
color="error"
onClick={handleResetStructure}
style={{ width: 150 }}
>
Reset columns
</Button>
</Confirmation>
<Confirmation
message={{
title: `Delete the table “${data?.name}”?`,
body: (
<>
<DialogContentText paragraph>
This will only delete the {name} configuration data.
</DialogContentText>
<DialogContentText>
You will not lose any data in your Firestore collection{" "}
<code>{data?.collection}</code>.
</DialogContentText>
</>
),
confirm: "Delete",
color: "error",
}}
functionName="onClick"
>
<Button
variant="outlined"
color="error"
onClick={handleDelete}
style={{ width: 150 }}
>
Delete table
</Button>
</Confirmation>
</Stack>
) : null
}
/>
);
}

View File

@@ -111,7 +111,7 @@ export default function ImportCsvWizard({
setOpen(false);
setTimeout(handleClose, 300);
}}
title="Import CSV"
title="Import CSV or TSV"
steps={
[
{

View File

@@ -53,13 +53,14 @@ export default function ActionFab({
const { tableState, rowyRun } = useProjectContext();
const { ref } = row;
const { config } = column as any;
const action = !value
? "run"
: value.undo
? "undo"
: value.redo
? "redo"
: "";
const hasRan = value && value.status;
const action: "run" | "undo" | "redo" = hasRan
? value.undo || config.undo?.enabled
? "undo"
: "redo"
: "run";
const [isRunning, setIsRunning] = useState(false);
const callableName: string =
@@ -98,50 +99,51 @@ export default function ActionFab({
}
const { message, success } = result;
setIsRunning(false);
enqueueSnackbar(JSON.stringify(message), {
variant: success ? "success" : "error",
});
enqueueSnackbar(
typeof message === "string" ? message : JSON.stringify(message),
{
variant: success ? "success" : "error",
}
);
};
const hasRan = value && value.status;
const actionState: "run" | "undo" | "redo" = hasRan
? value.undo
? "undo"
: "redo"
: "run";
const needsParams =
config.friction === "params" &&
Array.isArray(config.params) &&
config.params.length > 0;
const needsConfirmation =
(!config.friction || config.friction === "confirmation") &&
typeof config.confirmation === "string" &&
config.confirmation !== "";
const handleClick = async () => {
if (needsParams) {
return requestParams({
column,
row,
handleRun,
});
} else if (action === "undo" && config.undo.confirmation) {
return requestConfirmation({
title: `${column.name} Confirmation`,
body: config.undo.confirmation.replace(/\{\{(.*?)\}\}/g, replacer(row)),
confirm: "Run",
handleConfirm: () => handleRun(),
});
} else if (
action !== "undo" &&
config.friction === "confirmation" &&
typeof config.confirmation === "string"
) {
return requestConfirmation({
title: `${column.name} Confirmation`,
body: config.confirmation.replace(/\{\{(.*?)\}\}/g, replacer(row)),
confirm: "Run",
handleConfirm: () => handleRun(),
});
} else {
handleRun();
}
};
return (
<Fab
onClick={
needsParams
? () =>
requestParams({
column,
row,
handleRun,
})
: needsConfirmation
? () =>
requestConfirmation({
title: `${column.name ?? column.key} Confirmation`,
body: (actionState === "undo" && config.undoConfirmation
? config.undoConfirmation
: config.confirmation
).replace(/\{\{(.*?)\}\}/g, replacer(row)),
confirm: "Run",
handleConfirm: () => handleRun(),
})
: () => handleRun()
}
onClick={handleClick}
disabled={
isRunning ||
!!(
@@ -160,13 +162,13 @@ export default function ActionFab({
: theme.palette.background.default,
},
}}
aria-label={actionState}
aria-label={action}
{...props}
>
{isRunning ? (
<CircularProgressOptical color="secondary" size={16} />
) : (
getStateIcon(actionState, config)
getStateIcon(action, config)
)}
</Fab>
);

View File

@@ -45,6 +45,26 @@ const FORM_FIELD_SNIPPETS = [
options: ["ADMIN", "EDITOR", "VIEWER"],
},
},
{
label: "Number field",
value: {
type: "shortText",
InputProps: {
type: "number",
},
defaultValue: 1,
label: "Price",
name: "price",
},
},
{
label: "Check Box",
value: {
type: "checkbox",
label: "Breakfast included",
name: "breakfast",
},
},
];
export default function FormFieldSnippets() {

View File

@@ -3,10 +3,6 @@ import _get from "lodash/get";
import stringify from "json-stable-stringify-without-jsonify";
import {
Stepper,
Step,
StepButton,
StepContent,
Stack,
Grid,
TextField,
@@ -22,7 +18,6 @@ import {
FormHelperText,
Fab,
} from "@mui/material";
import ExpandIcon from "@mui/icons-material/KeyboardArrowDown";
import RunIcon from "@mui/icons-material/PlayArrow";
import RedoIcon from "@mui/icons-material/Refresh";
import UndoIcon from "@mui/icons-material/Undo";
@@ -36,6 +31,7 @@ import FormFieldSnippets from "./FormFieldSnippets";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import { useAppContext } from "@src/contexts/AppContext";
const CodeEditor = lazy(
() =>
@@ -44,7 +40,7 @@ const CodeEditor = lazy(
const Settings = ({ config, onChange }) => {
const { tableState, roles } = useProjectContext();
const { projectId } = useAppContext();
const [activeStep, setActiveStep] = useState<
"requirements" | "friction" | "action" | "undo" | "customization"
>("requirements");
@@ -65,17 +61,6 @@ const Settings = ({ config, onChange }) => {
const [codeValid, setCodeValid] = useState(true);
const scriptExtraLibs = [
[
"declare class ref {",
" /**",
" * Reference object of the row running the action script",
" */",
"static id:string",
"static path:string",
"static parentId:string",
"static tablePath:string",
"}",
].join("\n"),
[
"declare class actionParams {",
" /**",
@@ -325,7 +310,7 @@ const Settings = ({ config, onChange }) => {
Write the name of the callable function youve deployed to
your project.{" "}
<Link
href={`https://console.firebase.google.com/project/rowyio/functions/list`}
href={`https://console.firebase.google.com/project/${projectId}/functions/list`}
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -12,7 +12,7 @@ export default function Action({
onSubmit,
disabled,
}: IHeavyCellProps) {
const hasRan = value && value.status;
const hasRan = value && ![null, undefined].includes(value.status);
return (
<Stack

View File

@@ -30,5 +30,6 @@ export const config: IFieldConfig = {
SideDrawerField,
settings: Settings,
requireConfiguration: true,
sortKey: "status",
};
export default config;

View File

@@ -6,6 +6,8 @@ import EmailIcon from "@mui/icons-material/MailOutlined";
import BasicCell from "../_BasicCell/BasicCellValue";
import TextEditor from "@src/components/Table/editors/TextEditor";
import { filterOperators } from "../ShortText/Filter";
import BasicContextMenuActions from "../_BasicCell/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Email" */)
@@ -20,6 +22,7 @@ export const config: IFieldConfig = {
initializable: true,
icon: <EmailIcon />,
description: "Email address. Not validated.",
contextMenuActions: BasicContextMenuActions,
TableCell: withBasicCell(BasicCell),
TableEditor: TextEditor,
SideDrawerField,

View File

@@ -6,6 +6,7 @@ import LongTextIcon from "@mui/icons-material/Notes";
import BasicCell from "./BasicCell";
import TextEditor from "@src/components/Table/editors/TextEditor";
import { filterOperators } from "../ShortText/Filter";
import BasicContextMenuActions from "../_BasicCell/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -23,6 +24,7 @@ export const config: IFieldConfig = {
initializable: true,
icon: <LongTextIcon />,
description: "Text displayed on multiple lines.",
contextMenuActions: BasicContextMenuActions,
TableCell: withBasicCell(BasicCell),
TableEditor: TextEditor,
SideDrawerField,

View File

@@ -6,6 +6,7 @@ import NumberIcon from "@src/assets/icons/Number";
import BasicCell from "./BasicCell";
import TextEditor from "@src/components/Table/editors/TextEditor";
import { filterOperators } from "./Filter";
import BasicContextMenuActions from "../_BasicCell/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Number" */)
@@ -20,6 +21,7 @@ export const config: IFieldConfig = {
initializable: true,
icon: <NumberIcon />,
description: "Numeric value.",
contextMenuActions: BasicContextMenuActions,
TableCell: withBasicCell(BasicCell),
TableEditor: TextEditor,
SideDrawerField,

View File

@@ -6,6 +6,7 @@ import PhoneIcon from "@mui/icons-material/PhoneOutlined";
import BasicCell from "../_BasicCell/BasicCellValue";
import TextEditor from "@src/components/Table/editors/TextEditor";
import { filterOperators } from "../ShortText/Filter";
import BasicContextMenuActions from "../_BasicCell/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -21,6 +22,7 @@ export const config: IFieldConfig = {
initializable: true,
icon: <PhoneIcon />,
description: "Phone number stored as text. Not validated.",
contextMenuActions: BasicContextMenuActions,
TableCell: withBasicCell(BasicCell),
TableEditor: TextEditor,
SideDrawerField,

View File

@@ -5,6 +5,7 @@ import withHeavyCell from "../_withTableCell/withHeavyCell";
import RichTextIcon from "@mui/icons-material/TextFormat";
import BasicCell from "../_BasicCell/BasicCellNull";
import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor";
import BasicContextMenuActions from "../_BasicCell/BasicCellContextMenuActions";
const TableCell = lazy(
() => import("./TableCell" /* webpackChunkName: "TableCell-RichText" */)
@@ -25,6 +26,7 @@ export const config: IFieldConfig = {
initializable: true,
icon: <RichTextIcon />,
description: "HTML edited with a rich text editor.",
contextMenuActions: BasicContextMenuActions,
TableCell: withHeavyCell(BasicCell, TableCell),
TableEditor: withSideDrawerEditor(TableCell),
SideDrawerField,

View File

@@ -7,6 +7,7 @@ import BasicCell from "../_BasicCell/BasicCellValue";
import TextEditor from "@src/components/Table/editors/TextEditor";
import { filterOperators } from "./Filter";
import BasicContextMenuActions from "../_BasicCell/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
import(
@@ -26,6 +27,7 @@ export const config: IFieldConfig = {
initializable: true,
icon: <ShortTextIcon />,
description: "Text displayed on a single line.",
contextMenuActions: BasicContextMenuActions,
TableCell: withBasicCell(BasicCell),
TableEditor: TextEditor,
SideDrawerField,

View File

@@ -0,0 +1,68 @@
import Subheading from "@src/components/Table/ColumnMenu/Subheading";
import EditIcon from "@mui/icons-material/Edit";
import IconButton from "@mui/material/IconButton";
import Grid from "@mui/material/Grid";
import Divider from "@mui/material/Divider";
import { IConditionModal } from "./Settings";
import { createValueLabel } from "./utils/conditionListHelper";
interface I_ConditionList {
config: Record<string, any>;
setModal: React.Dispatch<React.SetStateAction<IConditionModal>>;
}
export default function ConditionList({ config, setModal }: I_ConditionList) {
const conditions = config?.conditions ?? [];
const noConditions = Boolean(conditions?.length < 1); // Double check this
if (noConditions) {
return (
<>
No conditions set yet
<br />
</>
);
}
return (
<>
<Subheading>Conditions</Subheading>
{conditions.map((condition, index) => {
return (
<>
<Grid
container
justifyContent="space-between"
alignItems={"center"}
>
<GridItem
index={index}
condition={condition}
setModal={setModal}
/>
</Grid>
<Divider />
</>
);
})}
</>
);
}
const GridItem = ({ condition, setModal, index }: any) => {
const noCondition = Boolean(!condition);
if (noCondition) return <></>;
return (
<>
{condition?.label}
<Grid item>
{createValueLabel(condition)}
<IconButton
onClick={() => setModal({ isOpen: true, condition, index })}
>
<EditIcon />
</IconButton>
</Grid>
</>
);
};

View File

@@ -0,0 +1,113 @@
import { useEffect } from "react";
import _find from "lodash/find";
import Modal from "@src/components/Modal";
import DeleteIcon from "@mui/icons-material/Delete";
import { default as Content } from "./ConditionModalContent";
import { EMPTY_STATE } from "./Settings";
import { isElement, isEmpty } from "lodash";
export default function ConditionModal({
modal,
setModal,
conditions,
setConditions,
}) {
const handleClose = () => setModal(EMPTY_STATE);
const handleSave = () => {
let _conditions = [...conditions];
_conditions[modal.index] = modal.condition;
setConditions(_conditions);
setModal(EMPTY_STATE);
};
const handleAdd = () => {
const labelIsEmpty = Boolean(modal.condition.label.length < 4);
const stringValueIsEmpty = Boolean(
modal.condition.type === "string" && modal.condition.value.length === 0
);
const hasDuplicate = Boolean(_find(conditions, modal.condition));
const validation = Boolean(
labelIsEmpty || stringValueIsEmpty || hasDuplicate
);
if (validation) return;
function setConditionHack(type, condition) {
let rCondition = condition;
if (type === "undefined") rCondition = { ...condition, value: undefined };
if (type === "boolean" && typeof condition.value === "object")
rCondition = { ...condition, value: false }; //Again 'rowy's multiselect does not accept default value'
return rCondition;
}
const modalCondition = setConditionHack(
modal.condition.type,
modal.condition
);
const noConditions = Boolean(conditions?.length === 0 || !conditions);
const arr = noConditions
? [modalCondition]
: [...conditions, modalCondition];
setConditions(arr);
setModal(EMPTY_STATE);
};
const handleRemove = () => {
const _newConditions = conditions.filter(
(c, index) => index !== modal.index
);
setConditions(_newConditions);
setModal(EMPTY_STATE);
};
const handleUpdate = (key: string) => (value) => {
const newState = {
...modal,
condition: { ...modal.condition, [key]: value },
};
setModal(newState);
};
const primaryAction = (index) => {
return index === null
? {
children: "Add condition",
onClick: () => handleAdd(),
disabled: false,
}
: {
children: "Save changes",
onClick: () => handleSave(),
disabled: false,
};
};
const secondaryAction = (index) => {
return index === null
? {
children: "Cancel",
onClick: () => setModal(EMPTY_STATE),
}
: {
startIcon: <DeleteIcon />,
children: "Remove condition",
onClick: () => handleRemove(),
};
};
useEffect(() => {
handleUpdate("operator")(modal.condition.operator ?? "==");
}, [modal.condition.type]);
return (
<Modal
open={modal.isOpen}
title={`${modal.index ? "Edit" : "Add"} condition`}
maxWidth={"xs"}
onClose={handleClose}
actions={{
primary: primaryAction(modal.index),
secondary: secondaryAction(modal.index),
}}
children={
<Content
condition={modal.condition}
conditions={conditions}
handleUpdate={handleUpdate}
/>
}
/>
);
}

View File

@@ -0,0 +1,104 @@
import _find from "lodash/find";
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import MultiSelect from "@rowy/multiselect";
interface I_ConditionModalContent {
handleUpdate: () => void;
modal: any;
}
const multiSelectOption = [
{ label: "Boolean", value: "boolean" },
{ label: "Number", value: "number" },
{ label: "String", value: "string" },
{ label: "Undefined", value: "undefined" },
{ label: "Null", value: "null" },
];
const booleanOptions = [
{ label: "True", value: "true" },
{ label: "False", value: "false" },
];
const operatorOptions = [
{ label: "Less than", value: "<" },
{ label: "Less than or equal", value: "<=" },
{ label: "Equal", value: "==" },
{ label: "Equal or more than", value: ">=" },
{ label: "More than", value: ">" },
];
export default function ConditionModalContent({
condition,
conditions,
handleUpdate,
}: any) {
const { label, operator, type, value } = condition;
const duplicateCond = Boolean(_find(conditions, condition));
const labelReqLen = Boolean(condition.label.length < 4);
return (
<>
<Typography variant="overline">DATA TYPE (input)</Typography>
<MultiSelect
options={multiSelectOption}
onChange={(v) => handleUpdate("type")(v)}
value={type}
multiple={false}
label="Select data type"
/>
{/** This is the issue where false is causing a problem */}
{/** To add defaultValue into MultiSelect?*/}
{type === "boolean" && (
<MultiSelect
options={booleanOptions}
onChange={(v) => handleUpdate("value")(v === "true")}
value={value ? "true" : "false"}
multiple={false}
label="Select condition value"
/>
)}
{type === "number" && (
<Grid container direction="row" justifyContent="space-between">
<div style={{ width: "45%" }}>
<MultiSelect
options={operatorOptions}
onChange={(v) => handleUpdate("operator")(v)}
value={operator}
multiple={false}
label="Select operator"
/>
</div>
<TextField
error={duplicateCond}
type="number"
label="Value"
value={value}
onChange={(e) => handleUpdate("value")(Number(e.target.value))}
helperText={
duplicateCond ? "Numeric Conditional already exists" : ""
}
/>
</Grid>
)}
{type === "string" && (
<TextField
error={duplicateCond}
fullWidth
label="Value"
value={value}
onChange={(e) => handleUpdate("value")(e.target.value)}
helperText={duplicateCond ? "string value already exists" : ""}
/>
)}
<TextField
error={labelReqLen}
value={label}
label="Label"
fullWidth
onChange={(e) => handleUpdate("label")(e.target.value)}
/>
</>
);
}

View File

@@ -0,0 +1,12 @@
import { IFilterOperator } from "../types";
export const filterOperators: IFilterOperator[] = [
{
label: "equals",
value: "==",
},
{
label: "not equals",
value: "!=",
},
];

View File

@@ -0,0 +1,71 @@
import { forwardRef, useMemo } from "react";
import { IPopoverInlineCellProps } from "../types";
import { ButtonBase } from "@mui/material";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import _find from "lodash/find";
import getLabel from "./utils/getLabelHelper";
import { LowPriority } from "@mui/icons-material";
export const StatusSingleSelect = forwardRef(function StatusSingleSelect(
{ column, value, showPopoverCell, disabled }: IPopoverInlineCellProps,
ref: React.Ref<any>
) {
const conditions = column.config?.conditions ?? [];
const lowPriorityOperator = ["<", "<=", ">=", ">"];
const otherOperator = conditions.filter(
(c) => !lowPriorityOperator.includes(c.operator)
);
/**Revisit this */
const sortLowPriorityList = conditions
.filter((c) => {
return lowPriorityOperator.includes(c.operator);
})
.sort((a, b) => {
const aDistFromValue = Math.abs(value - a.value);
const bDistFromValue = Math.abs(value - b.value);
//return the smallest distance
return aDistFromValue - bDistFromValue;
});
const sortedConditions = [...otherOperator, ...sortLowPriorityList];
const label = useMemo(
() => getLabel(value, sortedConditions),
[value, sortedConditions]
);
return (
<ButtonBase
onClick={() => showPopoverCell(true)}
ref={ref}
disabled={disabled}
className="cell-collapse-padding"
style={{
padding: "var(--cell-padding)",
paddingRight: 0,
height: "100%",
font: "inherit",
color: "inherit !important",
letterSpacing: "inherit",
textAlign: "inherit",
justifyContent: "flex-start",
}}
>
<div style={{ flexGrow: 1, overflow: "hidden" }}>{label}</div>
{!disabled && (
<ArrowDropDownIcon
className="row-hover-iconButton"
sx={{
flexShrink: 0,
mr: 0.5,
borderRadius: 1,
p: (32 - 24) / 2 / 8,
boxSizing: "content-box",
}}
/>
)}
</ButtonBase>
);
});
export default StatusSingleSelect;

View File

@@ -0,0 +1,48 @@
import _find from "lodash/find";
import { IPopoverCellProps } from "../types";
import MultiSelect_ from "@rowy/multiselect";
export default function StatusSingleSelect({
value,
onSubmit,
column,
parentRef,
showPopoverCell,
disabled,
}: IPopoverCellProps) {
const config = column.config ?? {};
const conditions = config.conditions ?? [];
/**Revisit eventually, can we abstract or use a helper function to clean this? */
const reMappedConditions = conditions.map((c) => {
let rValue = { ...c };
if (c.type === "number") {
if (c.operator === "<") rValue = { ...c, value: c.value - 1 };
if (c.operator === ">") rValue = { ...c, value: c.value + 1 };
}
return rValue;
});
return (
<MultiSelect_
value={value}
onChange={(v) => onSubmit(v)}
options={conditions.length >= 1 ? reMappedConditions : []} // this handles when conditions are deleted
multiple={false}
freeText={config.freeText}
disabled={disabled}
label={column.name as string}
labelPlural={column.name as string}
TextFieldProps={{
style: { display: "none" },
SelectProps: {
open: true,
MenuProps: {
anchorEl: parentRef,
anchorOrigin: { vertical: "bottom", horizontal: "left" },
transformOrigin: { vertical: "top", horizontal: "left" },
},
},
}}
onClose={() => showPopoverCell(false)}
/>
);
}

View File

@@ -1,21 +1,12 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { ISettingsProps } from "../types";
import Subheading from "@src/components/Table/ColumnMenu/Subheading";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Grid from "@mui/material/Grid";
import Divider from "@mui/material/Divider";
import EditIcon from "@mui/icons-material/Edit";
import AddIcon from "@mui/icons-material/Add";
import Modal from "@src/components/Modal";
import DeleteIcon from "@mui/icons-material/Delete";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import MultiSelect from "@rowy/multiselect";
import Button from "@mui/material/Button";
import ConditionModal from "./ConditionModal";
import ConditionList from "./ConditionList";
const EMPTY_STATE: {
export interface IConditionModal {
isOpen: boolean;
index: number | null;
condition: {
@@ -24,7 +15,9 @@ const EMPTY_STATE: {
label: string;
operator: string | undefined;
};
} = {
}
export const EMPTY_STATE: IConditionModal = {
index: null,
isOpen: false,
condition: {
@@ -34,190 +27,12 @@ const EMPTY_STATE: {
operator: "==",
},
};
const ConditionModal = ({ modal, setModal, conditions, setConditions }) => {
const handleClose = () => {
setModal(EMPTY_STATE);
};
const handleSave = () => {
let _conditions = [...conditions];
_conditions[modal.index] = modal.condition;
setConditions(_conditions);
setModal(EMPTY_STATE);
};
const handleAdd = () => {
setConditions(
conditions ? [...conditions, modal.condition] : [modal.condition]
);
setModal(EMPTY_STATE);
};
const handleRemove = () => {
let _conditions = [...conditions];
delete _conditions[modal.index];
setConditions(_conditions);
setModal(EMPTY_STATE);
};
const handleUpdate = (key: string) => (value) => {
setModal({ ...modal, condition: { ...modal.condition, [key]: value } });
};
useEffect(() => {
handleUpdate("operator")(modal.condition.operator ?? "==");
}, [modal.condition.type]);
return (
<Modal
open={modal.isOpen}
title={`${modal.index ? "Edit" : "Add"} condition`}
maxWidth={"xs"}
onClose={handleClose}
actions={{
primary:
modal.index === null
? {
children: "Add condition",
onClick: handleAdd,
disabled: false,
}
: {
children: "Save changes",
onClick: handleSave,
disabled: false,
},
secondary:
modal.index === null
? {
children: "Cancel",
onClick: () => {
setModal(EMPTY_STATE);
},
}
: {
startIcon: <DeleteIcon />,
children: "Remove condition",
onClick: handleRemove,
},
}}
children={
<>
<Typography variant="overline">DATA TYPE (input)</Typography>
<MultiSelect
options={[
{ label: "Boolean", value: "boolean" },
{ label: "Number", value: "number" },
{ label: "String", value: "string" },
{ label: "Undefined", value: "undefined" },
{ label: "Null", value: "null" },
]}
onChange={handleUpdate("type")}
value={modal.condition.type}
multiple={false}
label="Select data type"
/>
<Typography variant="overline">Condition </Typography>
{modal.condition.type === "boolean" && (
<MultiSelect
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
onChange={(v) => handleUpdate("value")(v === "true")}
value={modal.condition.value ? "true" : "false"}
multiple={false}
label="Select condition value"
/>
)}
{modal.condition.type === "number" && (
<Grid container direction="row" justifyContent="space-between">
<div style={{ width: "45%" }}>
<MultiSelect
options={[
{ label: "Less than", value: "<" },
{ label: "Less than or equal", value: "<=" },
{ label: "Equal", value: "==" },
{ label: "Equal or more than", value: ">=" },
{ label: "More than", value: ">" },
]}
onChange={handleUpdate("operator")}
value={modal.condition.operator}
multiple={false}
label="Select operator"
/>
</div>
<TextField
type="number"
label="Value"
value={modal.condition.value}
onChange={(e) => handleUpdate("value")(e.target.value)}
/>
</Grid>
)}
{modal.condition.type === "string" && (
<TextField
fullWidth
label="Value"
value={modal.condition.value}
onChange={(e) => handleUpdate("value")(e.target.value)}
/>
)}
<Typography variant="overline">Assigned label (output)</Typography>
<TextField
value={modal.condition.label}
label="Type the cell output"
fullWidth
onChange={(e) => handleUpdate("label")(e.target.value)}
/>
</>
}
/>
);
};
export default function Settings({ onChange, config }: ISettingsProps) {
const [modal, setModal] = useState(EMPTY_STATE);
const { conditions } = config;
return (
<>
<Subheading>Conditions</Subheading>
{conditions ? (
conditions.map((condition, index) => {
return (
<>
<Grid
container
justifyContent="space-between"
alignItems={"center"}
>
{condition.label}
<Grid item>
{["undefined", "null"].includes(condition.type)
? condition.type
: `${condition.type}:${
condition.type === "number" ? condition.operator : ""
}${
condition.type === "boolean"
? JSON.stringify(condition.value)
: condition.value
}`}
<IconButton
onClick={() => {
setModal({ isOpen: true, condition, index });
}}
>
<EditIcon />
</IconButton>
</Grid>
</Grid>
<Divider />
</>
);
})
) : (
<>
No conditions set yet
<br />
</>
)}
<ConditionList config={config} setModal={setModal} />
<Button
onClick={() => setModal({ ...EMPTY_STATE, isOpen: true })}
startIcon={<AddIcon />}

View File

@@ -1,25 +1,36 @@
import { Controller } from "react-hook-form";
import { ISideDrawerFieldProps } from "../types";
import { Grid } from "@mui/material";
import "@mui/lab";
import { useFieldStyles } from "@src/components/SideDrawer/Form/utils";
import { useStatusStyles } from "./styles";
export default function Rating({ control, column }: ISideDrawerFieldProps) {
const fieldClasses = useFieldStyles();
const ratingClasses = useStatusStyles();
import MultiSelect from "@rowy/multiselect";
import getLabel from "./utils/getLabelHelper";
export default function Status({
control,
column,
disabled,
}: ISideDrawerFieldProps) {
const config = column.config ?? {};
return (
<Controller
control={control}
name={column.key}
render={({ field: { value } }) => (
<Grid container alignItems="center" className={fieldClasses.root}>
<>{value}</>
</Grid>
render={({ field: { onChange, onBlur, value } }) => (
<>
<MultiSelect
value={getLabel(value, config?.conditions)}
onChange={onChange}
options={config?.conditions ?? []}
multiple={false}
freeText={config?.freeText}
disabled={disabled}
TextFieldProps={{
label: "",
hiddenLabel: true,
onBlur,
id: `sidedrawer-field-${column.key}`,
}}
/>
</>
)}
/>
);

View File

@@ -1,48 +0,0 @@
import { useMemo } from "react";
import { IHeavyCellProps } from "../types";
import { useStatusStyles } from "./styles";
import _find from "lodash/find";
export default function Status({ column, value }: IHeavyCellProps) {
const statusClasses = useStatusStyles();
const conditions = column.config?.conditions ?? [];
const label = useMemo(() => {
if (["null", "undefined"].includes(typeof value)) {
const condition = _find(conditions, (c) => c.type === typeof value);
return condition?.label;
} else if (typeof value === "number") {
const numberConditions = conditions.filter((c) => c.type === "number");
for (let i = 0; i < numberConditions.length; i++) {
const condition = numberConditions[i];
switch (condition.operator) {
case "<":
if (value < condition.value) return condition.label;
break;
case "<=":
if (value <= condition.value) return condition.label;
break;
case ">=":
if (value >= condition.value) return condition.label;
break;
case ">":
if (value > condition.value) return condition.label;
break;
case "==":
default:
if (value == condition.value) return condition.label;
break;
}
}
} else {
for (let i = 0; i < conditions.length; i++) {
const condition = conditions[i];
if (value == condition.value) return condition.label;
}
}
return JSON.stringify(value);
}, [value, conditions]);
return <>{label}</>;
}

View File

@@ -1,14 +1,14 @@
import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withHeavyCell from "../_withTableCell/withHeavyCell";
import StatusIcon from "@src/assets/icons/Status";
import BasicCell from "../_BasicCell/BasicCellNull";
import NullEditor from "@src/components/Table/editors/NullEditor";
const TableCell = lazy(
() => import("./TableCell" /* webpackChunkName: "TableCell-Status" */)
);
import { filterOperators } from "./Filter";
import BasicCell from "../_BasicCell/BasicCellNull";
import PopoverCell from "./PopoverCell";
import InlineCell from "./InlineCell";
import withPopoverCell from "../_withTableCell/withPopoverCell";
const SideDrawerField = lazy(
() =>
import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Status" */)
@@ -26,10 +26,16 @@ export const config: IFieldConfig = {
initializable: true,
icon: <StatusIcon />,
description: "Displays field value as custom status text. Read-only. ",
TableCell: withHeavyCell(BasicCell, TableCell),
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, {
anchorOrigin: { horizontal: "left", vertical: "bottom" },
transparent: true,
}),
TableEditor: NullEditor as any,
settings: Settings,
SideDrawerField,
requireConfiguration: true,
filter: {
operators: filterOperators,
},
};
export default config;

View File

@@ -0,0 +1,12 @@
export function createValueLabel(condition) {
const { operator, type, value } = condition || {};
const typeLabelMap = new Map([
["undefined", `${type}`],
["null", `${type}`],
["number", ` ${type}:${operator}${value}`],
["boolean", `${type}:${value}`],
]);
const string = typeLabelMap.get(type);
const validString = Boolean(typeof string === "string");
return validString ? string : JSON.stringify(value);
}

View File

@@ -0,0 +1,72 @@
import _find from "lodash/find";
type value = number | "string" | undefined | null;
interface condition {
type: string;
operator: string;
label: string;
value: value;
}
//TODO ADD TYPES
const getFalseyLabelFrom = (arr: condition[], value: string) => {
const falseyType = (value) =>
typeof value === "object" ? "null" : "undefined";
const conditions = _find(arr, (c) => c.type === falseyType(value));
return conditions?.label;
};
const getBooleanLabelFrom = (arr: condition[], value: string) => {
const boolConditions = arr.filter((c) => c.type === "boolean");
for (let c of boolConditions) {
if (value === c.value) return c.label;
}
};
/**
* @param arr conditional array
* @param value if value is not detected, conditional value becomes the default value
* @returns conditional's label || undefined
*/
const getNumericLabelFrom = (arr: condition[], value: number) => {
const numLabelFind = (v, c) => {
const condVal = c.value;
const operatorMap = new Map([
["<", v < condVal],
[">", v > condVal],
["<=", v <= condVal],
[">=", v >= condVal],
["==", v === condVal],
]);
return operatorMap.get(c.operator) ? c.label : undefined;
};
const numConditions = arr.filter((c) => c?.type === "number");
for (let c of numConditions) {
const label = numLabelFind(value, c);
if (typeof label === "string") return label;
}
};
const getLabelFrom = (arr, value) => {
const validVal = Boolean(value);
if (!validVal) return;
for (let c of arr) {
if (value === c.value) return c.label;
}
};
export default function getLabel(value, conditions) {
let _label: any = undefined;
const isBoolean = Boolean(typeof value === "boolean");
const notBoolean = Boolean(typeof value !== "boolean");
const isNullOrUndefined = Boolean(!value && notBoolean);
const isNumeric = Boolean(typeof value === "number");
if (isNullOrUndefined) _label = getFalseyLabelFrom(conditions, value);
else if (isBoolean) _label = getBooleanLabelFrom(conditions, value);
else if (isNumeric) _label = getNumericLabelFrom(conditions, value);
else _label = getLabelFrom(conditions, value);
return _label ?? value;
}

View File

@@ -6,6 +6,7 @@ import UrlIcon from "@mui/icons-material/Link";
import TableCell from "./TableCell";
import TextEditor from "@src/components/Table/editors/TextEditor";
import { filterOperators } from "../ShortText/Filter";
import BasicContextMenuActions from "../_BasicCell/BasicCellContextMenuActions";
const SideDrawerField = lazy(
() =>
@@ -21,6 +22,7 @@ export const config: IFieldConfig = {
initializable: true,
icon: <UrlIcon />,
description: "Web address. Not validated.",
contextMenuActions: BasicContextMenuActions,
TableCell: withBasicCell(TableCell),
TableEditor: TextEditor,
SideDrawerField,

View File

@@ -0,0 +1,73 @@
import _find from "lodash/find";
import Cut from "@mui/icons-material/ContentCut";
import CopyCells from "@src/assets/icons/CopyCells";
import Paste from "@mui/icons-material/ContentPaste";
import { useProjectContext } from "@src/contexts/ProjectContext";
export default function BasicContextMenuActions() {
const { contextMenuRef, tableState, deleteCell, updateCell } =
useProjectContext();
const columns = tableState?.columns;
const rows = tableState?.rows;
const selectedRowIndex = contextMenuRef?.current?.selectedCell
.rowIndex as number;
const selectedColIndex = contextMenuRef?.current?.selectedCell?.colIndex;
const selectedCol = _find(columns, { index: selectedColIndex });
const selectedRow = rows?.[selectedRowIndex];
const handleClose = () => {
contextMenuRef?.current?.setSelectedCell(null);
contextMenuRef?.current?.setAnchorEl(null);
};
const handleCopy = () => {
const cell = selectedRow?.[selectedCol.key];
// const onFail = () => console.log("Fail to copy");
// const onSuccess = () => console.log("Save to clipboard successful");
// const copy =
navigator.clipboard.writeText(JSON.stringify(cell));
// copy.then(onSuccess, onFail);
handleClose();
};
const handleCut = () => {
const cell = selectedRow?.[selectedCol.key];
const notUndefined = Boolean(typeof cell !== "undefined");
if (deleteCell && notUndefined)
deleteCell(selectedRow?.ref, selectedCol?.key);
handleClose();
};
const handlePaste = () => {
// console.log("home", rows);
const paste = navigator.clipboard.readText();
paste.then(async (clipText) => {
try {
const paste = await JSON.parse(clipText);
updateCell?.(selectedRow?.ref, selectedCol.key, paste);
} catch (error) {
//TODO check the coding style guide about error message
//Add breadcrumb handler her
// console.log(error);
}
});
handleClose();
};
// const handleDisable = () => {
// const cell = selectedRow?.[selectedCol.key];
// return typeof cell === "undefined" ? true : false;
// };
const cellMenuAction = [
{ label: "Cut", icon: <Cut />, onClick: handleCut },
{ label: "Copy", icon: <CopyCells />, onClick: handleCopy },
{ label: "Paste", icon: <Paste />, onClick: handlePaste },
];
return cellMenuAction;
}

View File

@@ -6,6 +6,7 @@ import {
IPopoverCellProps,
} from "../types";
import _find from "lodash/find";
import { makeStyles, createStyles } from "@mui/styles";
import { Popover, PopoverProps } from "@mui/material";
@@ -50,7 +51,7 @@ export default function withPopoverCell(
return function PopoverCell(props: FormatterProps<any>) {
const classes = useStyles();
const { transparent, ...popoverProps } = options ?? {};
const { updateCell } = useProjectContext();
const { deleteCell, updateCell, tableState } = useProjectContext();
const { validationRegex, required } = (props.column as any).config;
@@ -98,8 +99,18 @@ export default function withPopoverCell(
</ErrorBoundary>
);
//This is where we update the documents
const handleSubmit = (value: any) => {
if (updateCell && !options?.readOnly) {
const targetRow = _find(tableState?.rows, { id: props.row.ref.id });
const targetCell = targetRow?.[props.column.key];
const canDelete = Boolean(
typeof value === "undefined" && targetCell !== value
);
if (deleteCell && !options?.readOnly && canDelete) {
deleteCell(props.row.ref, props.column.key);
setLocalValue(value);
} else if (updateCell && !options?.readOnly) {
updateCell(props.row.ref, props.column.key, value);
setLocalValue(value);
}

View File

@@ -17,6 +17,7 @@ export interface IFieldConfig {
icon?: React.ReactNode;
description?: string;
setupGuideLink?: string;
contextMenuActions?: () => void;
TableCell: React.ComponentType<FormatterProps<any>>;
TableEditor: React.ComponentType<EditorProps<any, any>>;
SideDrawerField: React.ComponentType<ISideDrawerFieldProps>;
@@ -28,6 +29,7 @@ export interface IFieldConfig {
defaultValue?: any;
valueFormatter?: (value: any) => string;
};
sortKey?: string;
csvExportFormatter?: (value: any, config?: any) => string;
csvImportParser?: (value: string, config?: any) => any;
}

View File

@@ -36,6 +36,7 @@ const WIKI_PATHS = {
fieldTypesSupportedFields: "/field-types/supported-fields",
fieldTypesDerivative: "/field-types/derivative",
fieldTypesConnectTable: "/field-types/connect-table",
fieldTypesConnectService: "/field-types/connect-service",
fieldTypesAction: "/field-types/action",
fieldTypesAdd: "/field-types/add",

View File

@@ -14,6 +14,7 @@ import useSettings from "@src/hooks/useSettings";
import { useAppContext } from "./AppContext";
import { SideDrawerRef } from "@src/components/SideDrawer";
import { ColumnMenuRef } from "@src/components/Table/ColumnMenu";
import { ContextMenuRef } from "@src/components/Table/ContextMenu";
import { ImportWizardRef } from "@src/components/Wizards/ImportWizard";
import { rowyRun, IRowyRunRequestProps } from "@src/utils/rowyRun";
@@ -32,6 +33,7 @@ export type Table = {
audit?: boolean;
auditFieldCreatedBy?: string;
auditFieldUpdatedBy?: string;
readOnly?: boolean;
};
interface IRowyRun
@@ -60,6 +62,10 @@ export interface IProjectContext {
ignoreRequiredFields?: boolean
) => void;
deleteRow: (rowId) => void;
deleteCell: (
rowRef: firebase.firestore.DocumentReference,
fieldValue: string
) => void;
updateCell: (
ref: firebase.firestore.DocumentReference,
fieldName: string,
@@ -78,7 +84,7 @@ export interface IProjectContext {
description: string;
roles: string[];
section: string;
}) => void;
}) => Promise<void>;
updateTable: (data: {
id: string;
name?: string;
@@ -99,6 +105,8 @@ export interface IProjectContext {
dataGridRef: React.RefObject<DataGridHandle>;
// A ref to the side drawer state. Prevents unnecessary re-renders
sideDrawerRef: React.MutableRefObject<SideDrawerRef | undefined>;
//A ref to the cell menu. Prevents unnecessary re-render
contextMenuRef: React.MutableRefObject<ContextMenuRef | undefined>;
// A ref to the column menu. Prevents unnecessary re-renders
columnMenuRef: React.MutableRefObject<ColumnMenuRef | undefined>;
// A ref ot the import wizard. Prevents unnecessary re-renders
@@ -289,6 +297,17 @@ export const ProjectContextProvider: React.FC = ({ children }) => {
return;
};
const deleteCell: IProjectContext["deleteCell"] = (rowRef, fieldValue) => {
rowRef
.update({
[fieldValue]: firebase.firestore.FieldValue.delete(),
})
.then(
() => console.log("Field Value deleted"),
(error) => console.error("Failed to delete", error)
);
};
const updateCell: IProjectContext["updateCell"] = (
ref,
fieldName,
@@ -296,9 +315,7 @@ export const ProjectContextProvider: React.FC = ({ children }) => {
onSuccess
) => {
if (value === undefined) return;
const update = { [fieldName]: value };
if (table?.audit !== false) {
update[table?.auditFieldUpdatedBy || "_updatedBy"] = rowyUser(
currentUser!,
@@ -384,6 +401,7 @@ export const ProjectContextProvider: React.FC = ({ children }) => {
// A ref to the data grid. Contains data grid functions
const dataGridRef = useRef<DataGridHandle>(null);
const sideDrawerRef = useRef<SideDrawerRef>();
const contextMenuRef = useRef<ContextMenuRef>();
const columnMenuRef = useRef<ColumnMenuRef>();
const importWizardRef = useRef<ImportWizardRef>();
@@ -394,6 +412,7 @@ export const ProjectContextProvider: React.FC = ({ children }) => {
tableActions,
addRow,
addRows,
deleteCell,
updateCell,
deleteRow,
settingsActions,
@@ -403,6 +422,7 @@ export const ProjectContextProvider: React.FC = ({ children }) => {
table,
dataGridRef,
sideDrawerRef,
contextMenuRef,
columnMenuRef,
importWizardRef,
rowyRun: _rowyRun,

View File

@@ -28,7 +28,7 @@ const useDoc = (
(prevState.ref ? prevState.ref : db.doc(prevState.path))
.set({ ...newProps.data }, { merge: true })
.then(() => {})
.then(newProps.callback ? newProps.callback : () => {})
.catch((error) => {
console.log(error);
documentDispatch({ error });

View File

@@ -38,9 +38,10 @@ export default function useSettings() {
roles: string[];
schemaSource: any;
_initialColumns: Record<FieldType, boolean>;
_schema?: Record<string, any>;
}) => {
const { tables } = settingsState;
const { schemaSource, ...tableSettings } = data;
const { schemaSource, _initialColumns, _schema, ...tableSettings } = data;
const tableSchemaPath = `${
tableSettings.tableType !== "collectionGroup"
? TABLE_SCHEMAS
@@ -48,8 +49,11 @@ export default function useSettings() {
}/${tableSettings.id}`;
const tableSchemaDocRef = db.doc(tableSchemaPath);
// Get columns from schemaSource if provided
let columns: Record<string, any> = {};
// Get columns from imported table settings or schemaSource if provided
let columns: Record<string, any> =
Array.isArray(_schema?.columns) || !_schema?.columns
? {}
: _schema?.columns;
if (schemaSource) {
const schemaSourcePath = `${
tableSettings.tableType !== "collectionGroup"
@@ -60,7 +64,7 @@ export default function useSettings() {
columns = sourceDoc.get("columns");
}
// Add columns from `_initialColumns`
for (const [type, checked] of Object.entries(data._initialColumns)) {
for (const [type, checked] of Object.entries(_initialColumns)) {
if (
checked &&
!Object.values(columns).some((column) => column.type === type)
@@ -86,7 +90,12 @@ export default function useSettings() {
);
// Creates schema doc with columns
await tableSchemaDocRef.set({ columns }, { merge: true });
const { functionConfigPath, functionBuilderRef, ..._schemaToWrite } =
_schema ?? {};
await tableSchemaDocRef.set(
{ ..._schemaToWrite, columns },
{ merge: true }
);
};
const updateTable = async (data: {
@@ -96,15 +105,31 @@ export default function useSettings() {
section?: string;
description?: string;
roles?: string[];
_schema?: Record<string, any>;
[key: string]: any;
}) => {
const { tables } = settingsState;
const newTables = Array.isArray(tables) ? [...tables] : [];
const foundIndex = _findIndex(newTables, { id: data.id });
const tableIndex = foundIndex > -1 ? foundIndex : tables.length;
newTables[tableIndex] = { ...newTables[tableIndex], ...data };
const { _initialColumns, _schema, ...dataToWrite } = data;
newTables[tableIndex] = { ...newTables[tableIndex], ...dataToWrite };
await db.doc(SETTINGS).set({ tables: newTables }, { merge: true });
// Updates schema doc if present
if (_schema) {
const tableSchemaPath = `${
data.tableType !== "collectionGroup"
? TABLE_SCHEMAS
: TABLE_GROUP_SCHEMAS
}/${data.id}`;
const { functionConfigPath, functionBuilderRef, ..._schemaToWrite } =
_schema ?? {};
await db.doc(tableSchemaPath).update(_schemaToWrite);
}
};
const deleteTable = (id: string) => {

Some files were not shown because too many files have changed in this diff Show More