mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
Merge branch 'develop' into dependabot/npm_and_yarn/follow-redirects-1.14.7
This commit is contained in:
19
package.json
19
package.json
@@ -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
5
src/atoms/Table.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { atomWithHash } from "jotai/utils";
|
||||
|
||||
export const modalAtom = atomWithHash<
|
||||
"cloudLogs" | "extensions" | "webhooks" | "export" | ""
|
||||
>("modal", "");
|
||||
2
src/components/CodeEditor/extensions.d.ts
vendored
2
src/components/CodeEditor/extensions.d.ts
vendored
@@ -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;
|
||||
|
||||
2
src/components/CodeEditor/firebaseAuth.d.ts
vendored
2
src/components/CodeEditor/firebaseAuth.d.ts
vendored
@@ -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
32
src/components/CodeEditor/rowy.d.ts
vendored
Normal 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;
|
||||
}>;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
107
src/components/InfoTooltip.tsx
Normal file
107
src/components/InfoTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 it’s 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
|
||||
|
||||
37
src/components/RenderedMarkdown.tsx
Normal file
37
src/components/RenderedMarkdown.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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." />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
43
src/components/Table/ContextMenu/MenuContent.tsx
Normal file
43
src/components/Table/ContextMenu/MenuContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/Table/ContextMenu/MenuRow.tsx
Normal file
17
src/components/Table/ContextMenu/MenuRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/Table/ContextMenu/index.tsx
Normal file
57
src/components/Table/ContextMenu/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 }} />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" : "");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}) => {
|
||||
|
||||
121
src/components/TableHeader/Filters/FilterInputs.tsx
Normal file
121
src/components/TableHeader/Filters/FilterInputs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/components/TableHeader/Filters/FiltersPopover.tsx
Normal file
106
src/components/TableHeader/Filters/FiltersPopover.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
434
src/components/TableHeader/Filters/index.tsx
Normal file
434
src/components/TableHeader/Filters/index.tsx
Normal 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 don’t 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>
|
||||
);
|
||||
}
|
||||
50
src/components/TableHeader/Filters/useFilterInputs.ts
Normal file
50
src/components/TableHeader/Filters/useFilterInputs.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }} />
|
||||
Verification is not currently available for basic webhooks
|
||||
Specialized verification is not currently available for basic
|
||||
webhooks, you can add your own verification logic in the conditions
|
||||
section bellow.
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}`,
|
||||
|
||||
@@ -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;
|
||||
}`,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
101
src/components/TableSettings/ActionsMenu/ExportSettings.tsx
Normal file
101
src/components/TableSettings/ActionsMenu/ExportSettings.tsx
Normal 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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
177
src/components/TableSettings/ActionsMenu/ImportSettings.tsx
Normal file
177
src/components/TableSettings/ActionsMenu/ImportSettings.tsx
Normal 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>
|
||||
You’re importing new{" "}
|
||||
<b>
|
||||
{[
|
||||
hasExtensions && "extensions",
|
||||
hasWebhooks && "webhooks",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" and ")}
|
||||
</b>{" "}
|
||||
for this table. You’ll 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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
src/components/TableSettings/ActionsMenu/index.tsx
Normal file
64
src/components/TableSettings/ActionsMenu/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
129
src/components/TableSettings/DeleteMenu.tsx
Normal file
129
src/components/TableSettings/DeleteMenu.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -52,6 +52,7 @@ export default function CamelCaseId({
|
||||
</>
|
||||
)
|
||||
}
|
||||
FormHelperTextProps={{ component: "div" } as any}
|
||||
name={name}
|
||||
id={`field-${name}`}
|
||||
sx={{ "& .MuiInputBase-input": { fontFamily: "mono" } }}
|
||||
@@ -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 won’t be created until you add a row)
|
||||
If this collection does not exist, it won’t 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 table’s 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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function ImportCsvWizard({
|
||||
setOpen(false);
|
||||
setTimeout(handleClose, 300);
|
||||
}}
|
||||
title="Import CSV"
|
||||
title="Import CSV or TSV"
|
||||
steps={
|
||||
[
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 you’ve 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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,5 +30,6 @@ export const config: IFieldConfig = {
|
||||
SideDrawerField,
|
||||
settings: Settings,
|
||||
requireConfiguration: true,
|
||||
sortKey: "status",
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
68
src/components/fields/Status/ConditionList.tsx
Normal file
68
src/components/fields/Status/ConditionList.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
113
src/components/fields/Status/ConditionModal.tsx
Normal file
113
src/components/fields/Status/ConditionModal.tsx
Normal 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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
104
src/components/fields/Status/ConditionModalContent.tsx
Normal file
104
src/components/fields/Status/ConditionModalContent.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/components/fields/Status/Filter.tsx
Normal file
12
src/components/fields/Status/Filter.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IFilterOperator } from "../types";
|
||||
|
||||
export const filterOperators: IFilterOperator[] = [
|
||||
{
|
||||
label: "equals",
|
||||
value: "==",
|
||||
},
|
||||
{
|
||||
label: "not equals",
|
||||
value: "!=",
|
||||
},
|
||||
];
|
||||
71
src/components/fields/Status/InlineCell.tsx
Normal file
71
src/components/fields/Status/InlineCell.tsx
Normal 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;
|
||||
48
src/components/fields/Status/PopoverCell.tsx
Normal file
48
src/components/fields/Status/PopoverCell.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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}`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
12
src/components/fields/Status/utils/conditionListHelper.ts
Normal file
12
src/components/fields/Status/utils/conditionListHelper.ts
Normal 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);
|
||||
}
|
||||
72
src/components/fields/Status/utils/getLabelHelper.ts
Normal file
72
src/components/fields/Status/utils/getLabelHelper.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user