Merge branch 'develop' into feat/webhooks

* develop:
  ConnectServiceSelect: fix list edge styles on dark theme
  remove duplicate ConnectServiceSelect code
  setup: remove insecure rule insecure rule that allows anyone to access any part of your database
  Test page: fix small switch spacing
  SideDrawer: add option to show hidden fields
  fix Switch with label spacing
  fix hidden fields & filters not working with multiple table views
  column settings: add basic validation on blur & change
  ConfirmationDialog: add hideCancel prop
  remove monaco diff editor validation for firestore rules
  connect service config
  fix action SchemaPath ref
  NavDrawer: use material icon for closing drawer
  add DiffEditor to setup rules step
  add DiffEditor
This commit is contained in:
Sidney Alcantara
2021-11-01 17:09:33 +11:00
48 changed files with 735 additions and 823 deletions

View File

@@ -47,8 +47,8 @@ export default function CodeEditorHelper({
justifyContent="space-between"
sx={{ my: 1 }}
>
<Typography variant="body2" color="textSecondary">
You can access:
<Typography variant="body2" color="textSecondary" sx={{ mr: 0.5 }}>
Available:
</Typography>
<Grid container spacing={1}>

View File

@@ -0,0 +1,93 @@
import {
DiffEditor as MonacoDiffEditor,
DiffEditorProps,
EditorProps,
} from "@monaco-editor/react";
import { useTheme, Box, BoxProps } from "@mui/material";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import ResizeBottomRightIcon from "@src/assets/icons/ResizeBottomRight";
import useMonacoCustomizations, {
IUseMonacoCustomizationsProps,
} from "./useMonacoCustomizations";
export interface IDiffEditorProps
extends Partial<DiffEditorProps>,
IUseMonacoCustomizationsProps {
onChange?: EditorProps["onChange"];
containerProps?: Partial<BoxProps>;
}
export default function DiffEditor({
onChange,
minHeight = 100,
disabled,
error,
containerProps,
extraLibs,
diagnosticsOptions,
onUnmount,
...props
}: IDiffEditorProps) {
const theme = useTheme();
const { boxSx } = useMonacoCustomizations({
minHeight,
disabled,
error,
extraLibs,
diagnosticsOptions,
onUnmount,
});
// Needs manual patch since `onMount` prop is not available in `DiffEditor`
// https://github.com/suren-atoyan/monaco-react/issues/281
const handleEditorMount: DiffEditorProps["onMount"] = (editor, monaco) => {
const modifiedEditor = editor.getModifiedEditor();
modifiedEditor.onDidChangeModelContent((ev) => {
onChange?.(modifiedEditor.getValue(), ev);
});
props.onMount?.(editor, monaco);
};
return (
<Box sx={{ ...boxSx, ...containerProps?.sx }}>
<MonacoDiffEditor
language="javascript"
loading={<CircularProgressOptical size={20} sx={{ m: 2 }} />}
className="editor"
{...props}
onMount={handleEditorMount}
options={
{
readOnly: disabled,
fontFamily: theme.typography.fontFamilyMono,
rulers: [80],
minimap: { enabled: false },
lineNumbersMinChars: 4,
lineDecorationsWidth: "18",
automaticLayout: true,
fixedOverflowWidgets: true,
tabSize: 2,
...props.options,
} as any
}
/>
<ResizeBottomRightIcon
aria-label="Resize code editor"
color="action"
sx={{
position: "absolute",
bottom: 1,
right: 1,
zIndex: 1,
}}
/>
</Box>
);
}

View File

@@ -126,8 +126,8 @@
"editorGutter.modifiedBackground": "#d4a72c66",
"editorGutter.addedBackground": "#4ac26b66",
"editorGutter.deletedBackground": "#ff818266",
"diffEditor.insertedTextBackground": "#dafbe1",
"diffEditor.removedTextBackground": "#ffebe9",
"diffEditor.insertedTextBackground": "#85e89d33",
"diffEditor.removedTextBackground": "#f9758326",
"scrollbar.shadow": "#6a737d33",
"scrollbarSlider.background": "#959da533",
"scrollbarSlider.hoverBackground": "#959da544",

View File

@@ -1,39 +1,26 @@
import React, { useState, useEffect } from "react";
import Editor, { EditorProps, useMonaco } from "@monaco-editor/react";
import type { editor, languages } from "monaco-editor/esm/vs/editor/editor.api";
import githubLightTheme from "./github-light-default.json";
import githubDarkTheme from "./github-dark-default.json";
import { useState } from "react";
import Editor, { EditorProps } from "@monaco-editor/react";
import type { editor } from "monaco-editor/esm/vs/editor/editor.api";
import { useTheme, Box, BoxProps } from "@mui/material";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import ResizeBottomRightIcon from "@src/assets/icons/ResizeBottomRight";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { getFieldProp } from "@src/components/fields";
import useMonacoCustomizations, {
IUseMonacoCustomizationsProps,
} from "./useMonacoCustomizations";
/* eslint-disable import/no-webpack-loader-syntax */
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 extensionsDefs from "!!raw-loader!./extensions.d.ts";
export interface ICodeEditorProps extends Partial<EditorProps> {
export interface ICodeEditorProps
extends Partial<EditorProps>,
IUseMonacoCustomizationsProps {
value: string;
minHeight?: number;
disabled?: boolean;
error?: boolean;
containerProps?: Partial<BoxProps>;
extraLibs?: string[];
onValidate?: EditorProps["onValidate"];
onValidStatusUpdate?: (result: {
isValid: boolean;
markers: editor.IMarker[];
}) => void;
diagnosticsOptions?: languages.typescript.DiagnosticsOptions;
onUnmount?: () => void;
}
export default function CodeEditor({
@@ -43,210 +30,44 @@ export default function CodeEditor({
error,
containerProps,
extraLibs,
onValidate,
onValidStatusUpdate,
extraLibs,
diagnosticsOptions,
onUnmount,
...props
}: ICodeEditorProps) {
const theme = useTheme();
const { tableState } = useProjectContext();
// Store editor value to prevent code editor values not being saved when
// Side Drawer is in the middle of a refresh
const [initialEditorValue] = useState(value ?? "");
const monaco = useMonaco();
useEffect(() => {
return () => {
onUnmount?.();
};
}, []);
const { boxSx } = useMonacoCustomizations({
minHeight,
disabled,
error,
extraLibs,
diagnosticsOptions,
onUnmount,
});
const onValidate_: EditorProps["onValidate"] = (markers) => {
if (onValidStatusUpdate)
onValidStatusUpdate({ isValid: markers.length <= 0, markers });
else if (onValidate) onValidate(markers);
onValidStatusUpdate?.({ isValid: markers.length <= 0, markers });
onValidate?.(markers);
};
useEffect(() => {
if (!monaco) {
// useMonaco returns a monaco instance but initialisation is done asynchronously
// dont execute the logic until the instance is initialised
return;
}
setTimeout(() => {
try {
monaco.editor.defineTheme("github-light", githubLightTheme as any);
monaco.editor.defineTheme("github-dark", githubDarkTheme as any);
monaco.editor.setTheme("github-" + theme.palette.mode);
} catch (error) {
console.error("Could not set Monaco theme: ", error);
}
});
}, [monaco, theme.palette.mode]);
useEffect(() => {
if (!monaco) {
// useMonaco returns a monaco instance but initialisation is done asynchronously
// dont execute the logic until the instance is initialised
return;
}
try {
monaco.editor.defineTheme("github-light", githubLightTheme as any);
monaco.editor.defineTheme("github-dark", githubDarkTheme as any);
monaco.languages.typescript.javascriptDefaults.addExtraLib(firestoreDefs);
monaco.languages.typescript.javascriptDefaults.addExtraLib(
firebaseAuthDefs
);
monaco.languages.typescript.javascriptDefaults.addExtraLib(
firebaseStorageDefs
);
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions(
diagnosticsOptions ?? {
noSemanticValidation: true,
noSyntaxValidation: false,
}
);
// compiler options
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2020,
allowNonTsExtensions: true,
});
if (extraLibs) {
monaco.languages.typescript.javascriptDefaults.addExtraLib(
extraLibs.join("\n"),
"ts:filename/extraLibs.d.ts"
);
}
monaco.languages.typescript.javascriptDefaults.addExtraLib(
utilsDefs,
"ts:filename/utils.d.ts"
);
const rowDefinition =
Object.keys(tableState?.columns!)
.map((columnKey: string) => {
const column = tableState?.columns[columnKey];
return `static ${columnKey}: ${getFieldProp("type", column.type)}`;
})
.join(";\n") + ";";
const availableFields = Object.keys(tableState?.columns!)
.map((columnKey: string) => `"${columnKey}"`)
.join("|\n");
monaco.languages.typescript.javascriptDefaults.addExtraLib(
[
"/**",
" * extensions type configuration",
" */",
"// basic types that are used in all places",
`type Row = {${rowDefinition}};`,
`type Field = ${availableFields} | string | object;`,
`type Fields = Field[];`,
extensionsDefs,
].join("\n"),
"ts:filename/extensions.d.ts"
);
monaco.languages.typescript.javascriptDefaults.addExtraLib(
[
"declare var require: any;",
"declare var Buffer: any;",
"const ref: FirebaseFirestore.DocumentReference;",
"const storage: firebasestorage.Storage;",
"const db: FirebaseFirestore.Firestore;",
"const auth: adminauth.BaseAuth;",
"declare class row {",
" /**",
" * Returns the row fields",
" */",
rowDefinition,
"}",
].join("\n"),
"ts:filename/rowFields.d.ts"
);
} catch (error) {
console.error(
"An error occurred during initialization of Monaco: ",
error
);
}
}, [tableState?.columns, monaco, diagnosticsOptions, extraLibs]);
return (
<Box
sx={{
minWidth: 400,
minHeight,
height: minHeight,
borderRadius: 1,
resize: "vertical",
overflow: "hidden",
position: "relative",
backgroundColor: disabled ? "transparent" : theme.palette.action.input,
"&::after": {
content: '""',
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
pointerEvents: "none",
borderRadius: "inherit",
boxShadow: `0 -1px 0 0 ${theme.palette.text.disabled} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
transition: theme.transitions.create("box-shadow", {
duration: theme.transitions.duration.short,
}),
},
"&:hover::after": {
boxShadow: `0 -1px 0 0 ${theme.palette.text.primary} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
},
"&:focus-within::after": {
boxShadow: `0 -2px 0 0 ${theme.palette.primary.main} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
},
...(error
? {
"&::after, &:hover::after, &:focus-within::after": {
boxShadow: `0 -2px 0 0 ${theme.palette.error.main} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
},
}
: {}),
"& .editor": {
// Overwrite user-select: none that causes editor
// to not be focusable in Safari
userSelect: "auto",
height: "100%",
},
"& .monaco-editor, & .monaco-editor .margin, & .monaco-editor-background":
{
backgroundColor: "transparent",
},
...containerProps?.sx,
}}
>
<Box sx={{ ...boxSx, ...containerProps?.sx }}>
<Editor
defaultLanguage="javascript"
value={initialEditorValue}
onValidate={onValidate_}
loading={<CircularProgressOptical size={20} sx={{ m: 2 }} />}
className="editor"
{...props}
onValidate={onValidate_}
options={{
readOnly: disabled,
fontFamily: theme.typography.fontFamilyMono,
@@ -262,7 +83,7 @@ export default function CodeEditor({
/>
<ResizeBottomRightIcon
aria-label="This code editor is resizable"
aria-label="Resize code editor"
color="action"
sx={{
position: "absolute",

View File

@@ -0,0 +1,218 @@
import { useEffect } from "react";
import { useMonaco } from "@monaco-editor/react";
import type { languages } from "monaco-editor/esm/vs/editor/editor.api";
import githubLightTheme from "./github-light-default.json";
import githubDarkTheme from "./github-dark-default.json";
import { useTheme } from "@mui/material";
import type { SxProps, Theme } from "@mui/system";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { getFieldProp } from "@src/components/fields";
/* eslint-disable import/no-webpack-loader-syntax */
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 extensionsDefs from "!!raw-loader!./extensions.d.ts";
export interface IUseMonacoCustomizationsProps {
minHeight?: number;
disabled?: boolean;
error?: boolean;
extraLibs?: string[];
diagnosticsOptions?: languages.typescript.DiagnosticsOptions;
onUnmount?: () => void;
}
export default function useMonacoCustomizations({
minHeight,
disabled,
error,
extraLibs,
diagnosticsOptions,
onUnmount,
}: IUseMonacoCustomizationsProps) {
const theme = useTheme();
const { tableState } = useProjectContext();
const monaco = useMonaco();
useEffect(() => {
return () => {
onUnmount?.();
};
}, []);
useEffect(() => {
if (!monaco) {
// useMonaco returns a monaco instance but initialisation is done asynchronously
// dont execute the logic until the instance is initialised
return;
}
setTimeout(() => {
try {
monaco.editor.defineTheme("github-light", githubLightTheme as any);
monaco.editor.defineTheme("github-dark", githubDarkTheme as any);
monaco.editor.setTheme("github-" + theme.palette.mode);
} catch (error) {
console.error("Could not set Monaco theme: ", error);
}
});
}, [monaco, theme.palette.mode]);
useEffect(() => {
if (!monaco) {
// useMonaco returns a monaco instance but initialisation is done asynchronously
// dont execute the logic until the instance is initialised
return;
}
try {
monaco.editor.defineTheme("github-light", githubLightTheme as any);
monaco.editor.defineTheme("github-dark", githubDarkTheme as any);
monaco.languages.typescript.javascriptDefaults.addExtraLib(firestoreDefs);
monaco.languages.typescript.javascriptDefaults.addExtraLib(
firebaseAuthDefs
);
monaco.languages.typescript.javascriptDefaults.addExtraLib(
firebaseStorageDefs
);
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions(
diagnosticsOptions ?? {
noSemanticValidation: true,
noSyntaxValidation: false,
}
);
// compiler options
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2020,
allowNonTsExtensions: true,
});
if (extraLibs) {
monaco.languages.typescript.javascriptDefaults.addExtraLib(
extraLibs.join("\n"),
"ts:filename/extraLibs.d.ts"
);
}
monaco.languages.typescript.javascriptDefaults.addExtraLib(
utilsDefs,
"ts:filename/utils.d.ts"
);
const rowDefinition =
Object.keys(tableState?.columns!)
.map((columnKey: string) => {
const column = tableState?.columns[columnKey];
return `static ${columnKey}: ${getFieldProp("type", column.type)}`;
})
.join(";\n") + ";";
const availableFields = Object.keys(tableState?.columns!)
.map((columnKey: string) => `"${columnKey}"`)
.join("|\n");
monaco.languages.typescript.javascriptDefaults.addExtraLib(
[
"/**",
" * extensions type configuration",
" */",
"// basic types that are used in all places",
`type Row = {${rowDefinition}};`,
`type Field = ${availableFields} | string | object;`,
`type Fields = Field[];`,
extensionsDefs,
].join("\n"),
"ts:filename/extensions.d.ts"
);
monaco.languages.typescript.javascriptDefaults.addExtraLib(
[
"declare var require: any;",
"declare var Buffer: any;",
"const ref: FirebaseFirestore.DocumentReference;",
"const storage: firebasestorage.Storage;",
"const db: FirebaseFirestore.Firestore;",
"const auth: adminauth.BaseAuth;",
"declare class row {",
" /**",
" * Returns the row fields",
" */",
rowDefinition,
"}",
].join("\n"),
"ts:filename/rowFields.d.ts"
);
} catch (error) {
console.error(
"An error occurred during initialization of Monaco: ",
error
);
}
}, [tableState?.columns, monaco, diagnosticsOptions, extraLibs]);
const boxSx: SxProps<Theme> = {
minWidth: 400,
minHeight,
height: minHeight,
borderRadius: 1,
resize: "vertical",
overflow: "hidden",
position: "relative",
backgroundColor: disabled ? "transparent" : theme.palette.action.input,
"&::after": {
content: '""',
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
pointerEvents: "none",
borderRadius: "inherit",
boxShadow: `0 -1px 0 0 ${theme.palette.text.disabled} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
transition: theme.transitions.create("box-shadow", {
duration: theme.transitions.duration.short,
}),
},
"&:hover::after": {
boxShadow: `0 -1px 0 0 ${theme.palette.text.primary} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
},
"&:focus-within::after": {
boxShadow: `0 -2px 0 0 ${theme.palette.primary.main} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
},
...(error
? {
"&::after, &:hover::after, &:focus-within::after": {
boxShadow: `0 -2px 0 0 ${theme.palette.error.main} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
},
}
: {}),
"& .editor": {
// Overwrite user-select: none that causes editor
// to not be focusable in Safari
userSelect: "auto",
height: "100%",
},
"& .monaco-editor, & .monaco-editor .margin, & .monaco-editor-background": {
backgroundColor: "transparent",
},
};
return { boxSx };
}

View File

@@ -17,6 +17,7 @@ export default function Confirmation({
customBody,
body,
cancel,
hideCancel,
confirm,
confirmationCommand,
handleConfirm,
@@ -55,7 +56,9 @@ export default function Confirmation({
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>{cancel ?? "Cancel"}</Button>
{!hideCancel && (
<Button onClick={handleClose}>{cancel ?? "Cancel"}</Button>
)}
<Button
onClick={() => {
handleConfirm();

View File

@@ -4,6 +4,7 @@ export type confirmationProps =
customBody?: React.ReactNode;
body?: string;
cancel?: string;
hideCancel?: boolean;
confirm?: string | JSX.Element;
confirmationCommand?: string;
handleConfirm: () => void;

View File

@@ -1,204 +0,0 @@
import React, { useEffect, useState } from "react";
import clsx from "clsx";
import { useDebouncedCallback } from "use-debounce";
import _get from "lodash/get";
import {
Button,
Checkbox,
Divider,
Grid,
InputAdornment,
List,
ListItemIcon,
ListItemText,
MenuItem,
TextField,
Typography,
Radio,
} from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import { IConnectServiceSelectProps } from ".";
import useStyles from "./styles";
import Loading from "@src/components/Loading";
export interface IPopupContentsProps
extends Omit<IConnectServiceSelectProps, "className" | "TextFieldProps"> {}
// TODO: Implement infinite scroll here
export default function PopupContents({
value = [],
onChange,
config,
docRef,
}: IPopupContentsProps) {
const url = config.url;
const titleKey = config.titleKey ?? config.primaryKey;
const subtitleKey = config.subtitleKey;
const resultsKey = config.resultsKey;
const primaryKey = config.primaryKey;
const multiple = Boolean(config.multiple);
const classes = useStyles();
// Webservice search query
const [query, setQuery] = useState("");
// Webservice response
const [response, setResponse] = useState<any | null>(null);
const [docData, setDocData] = useState<any | null>(null);
useEffect(() => {
docRef.get().then((d) => setDocData(d.data()));
}, []);
const hits: any["hits"] = _get(response, resultsKey) ?? [];
const [search] = useDebouncedCallback(
async (query: string) => {
if (!docData) return;
if (!url) return;
const uri = new URL(url),
params = { q: query };
Object.keys(params).forEach((key) =>
uri.searchParams.append(key, params[key])
);
const resp = await fetch(uri.toString(), {
method: "POST",
body: JSON.stringify(docData),
headers: { "content-type": "application/json" },
});
const jsonBody = await resp.json();
setResponse(jsonBody);
},
1000,
{ leading: true }
);
useEffect(() => {
search(query);
}, [query, docData]);
if (!response) return <Loading />;
const select = (hit: any) => () => {
if (multiple) onChange([...value, hit]);
else onChange([hit]);
};
const deselect = (hit: any) => () => {
if (multiple)
onChange(value.filter((v) => v[primaryKey] !== hit[primaryKey]));
else onChange([]);
};
const selectedValues = value?.map((item) => _get(item, primaryKey));
const clearSelection = () => onChange([]);
return (
<Grid container direction="column" className={classes.grid}>
<Grid item className={classes.searchRow}>
<TextField
value={query}
onChange={(e) => setQuery(e.target.value)}
fullWidth
variant="filled"
margin="dense"
label="Search items"
className={classes.noMargins}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<SearchIcon />
</InputAdornment>
),
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
/>
</Grid>
<Grid item xs className={classes.listRow}>
<List className={classes.list}>
{hits.map((hit) => {
const isSelected =
selectedValues.indexOf(_get(hit, primaryKey)) !== -1;
console.log(`Selected Values: ${selectedValues}`);
return (
<React.Fragment key={_get(hit, primaryKey)}>
<MenuItem
dense
onClick={isSelected ? deselect(hit) : select(hit)}
>
<ListItemIcon className={classes.checkboxContainer}>
{multiple ? (
<Checkbox
edge="start"
checked={isSelected}
tabIndex={-1}
color="secondary"
className={classes.checkbox}
disableRipple
inputProps={{
"aria-labelledby": `label-${_get(hit, primaryKey)}`,
}}
/>
) : (
<Radio
edge="start"
checked={isSelected}
tabIndex={-1}
color="secondary"
className={classes.checkbox}
disableRipple
inputProps={{
"aria-labelledby": `label-${_get(hit, primaryKey)}`,
}}
/>
)}
</ListItemIcon>
<ListItemText
id={`label-${_get(hit, primaryKey)}`}
primary={_get(hit, titleKey)}
secondary={!subtitleKey ? "" : _get(hit, subtitleKey)}
/>
</MenuItem>
<Divider className={classes.divider} />
</React.Fragment>
);
})}
</List>
</Grid>
{multiple && (
<Grid item className={clsx(classes.footerRow, classes.selectedRow)}>
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Typography
variant="button"
color="textSecondary"
className={classes.selectedNum}
>
{value?.length} of {hits?.length}
</Typography>
<Button
disabled={!value || value.length === 0}
onClick={clearSelection}
color="primary"
className={classes.selectAllButton}
>
Clear selection
</Button>
</Grid>
</Grid>
)}
</Grid>
);
}

View File

@@ -1,73 +0,0 @@
import { lazy, Suspense } from "react";
import clsx from "clsx";
import { TextField, TextFieldProps } from "@mui/material";
import useStyles from "./styles";
import Loading from "@src/components/Loading";
import ErrorBoundary from "@src/components/ErrorBoundary";
const PopupContents = lazy(
() => import("./PopupContents" /* webpackChunkName: "PopupContents" */)
);
export type ServiceValue = { value: string; [prop: string]: any };
export interface IConnectServiceSelectProps {
value: ServiceValue[];
onChange: (value: ServiceValue[]) => void;
row: any;
config: {
displayKey: string;
[key: string]: any;
};
editable?: boolean;
/** Optional style overrides for root MUI `TextField` component */
className?: string;
/** Override any props of the root MUI `TextField` component */
TextFieldProps?: Partial<TextFieldProps>;
docRef: firebase.default.firestore.DocumentReference;
}
export default function ConnectServiceSelect({
value = [],
className,
TextFieldProps = {},
...props
}: IConnectServiceSelectProps) {
const classes = useStyles();
const sanitisedValue = Array.isArray(value) ? value : [];
return (
<TextField
label=""
hiddenLabel
variant={"filled" as any}
select
value={sanitisedValue}
className={clsx(classes.root, className)}
{...TextFieldProps}
SelectProps={{
renderValue: (value) => `${(value as any[]).length} selected`,
displayEmpty: true,
classes: { root: classes.selectRoot },
...TextFieldProps.SelectProps,
// Must have this set to prevent MUI transforming `value`
// prop for this component to a comma-separated string
MenuProps: {
classes: { paper: classes.paper, list: classes.menuChild },
MenuListProps: { disablePadding: true },
anchorOrigin: { vertical: "bottom", horizontal: "center" },
transformOrigin: { vertical: "top", horizontal: "center" },
...TextFieldProps.SelectProps?.MenuProps,
},
}}
>
<ErrorBoundary>
<Suspense fallback={<Loading />}>
<PopupContents value={sanitisedValue} {...props} />
</Suspense>
</ErrorBoundary>
</TextField>
);
}

View File

@@ -1,83 +0,0 @@
import { makeStyles, createStyles } from "@mui/styles";
export const useStyles = makeStyles((theme) =>
createStyles({
root: { minWidth: 200 },
selectRoot: { paddingRight: theme.spacing(4) },
paper: { overflow: "hidden", maxHeight: "calc(100% - 48px)" },
menuChild: {
padding: `0 ${theme.spacing(2)}`,
minWidth: 340,
// Need to set fixed height here so popup is positioned correctly
height: 340,
},
grid: { outline: 0 },
noMargins: { margin: 0 },
searchRow: { marginTop: theme.spacing(2) },
listRow: {
background: `${theme.palette.background.paper} no-repeat`,
position: "relative",
margin: theme.spacing(0, -2),
maxWidth: `calc(100% + ${theme.spacing(4)})`,
"&::before, &::after": {
content: '""',
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 9,
display: "block",
height: 16,
background: `linear-gradient(to bottom, #fff, rgba(255, 255, 255, 0))`,
},
"&::after": {
top: "auto",
bottom: 0,
background: `linear-gradient(to top, #fff, rgba(255, 255, 255, 0))`,
},
},
list: () => {
let maxHeightDeductions = 0;
maxHeightDeductions -= 64; // search box
maxHeightDeductions -= 48; // multiple
maxHeightDeductions += 8; // footer padding
return {
padding: theme.spacing(2, 0),
overflowY: "auto" as "auto",
// height: `calc(340px - ${-maxHeightDeductions}px)`,
height: 340 + maxHeightDeductions,
};
},
checkboxContainer: { minWidth: theme.spacing(36 / 8) },
checkbox: {
padding: theme.spacing(6 / 8, 9 / 8),
"&:hover": { background: "transparent" },
},
divider: { margin: theme.spacing(0, 2, 0, 6.5) },
footerRow: { marginBottom: theme.spacing(2) },
selectedRow: {
"$listRow + &": { marginTop: -theme.spacing(1) },
"$footerRow + &": { marginTop: -theme.spacing(2) },
marginBottom: 0,
"& > div": { height: 48 },
},
selectAllButton: { marginRight: -theme.spacing(1) },
selectedNum: { fontFeatureSettings: '"tnum"' },
})
);
export default useStyles;

View File

@@ -15,7 +15,7 @@ import HomeIcon from "@mui/icons-material/HomeOutlined";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import ProjectSettingsIcon from "@mui/icons-material/BuildCircleOutlined";
import UserManagementIcon from "@mui/icons-material/AccountCircleOutlined";
import CloseIcon from "@src/assets/icons/Backburger";
import CloseIcon from "@mui/icons-material/MenuOpen";
import PinIcon from "@mui/icons-material/PushPinOutlined";
import UnpinIcon from "@mui/icons-material/PushPin";

View File

@@ -31,7 +31,7 @@ export default function SetupItem({
<ArrowIcon aria-label="Item" color="primary" />
)}
<Stack spacing={2} alignItems="flex-start">
<Stack spacing={2} alignItems="flex-start" style={{ flexGrow: 1 }}>
<Typography variant="inherit">{title}</Typography>
{children}

View File

@@ -7,21 +7,28 @@ import {
Checkbox,
Button,
Link,
TextField,
Grid,
} from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import InfoIcon from "@mui/icons-material/InfoOutlined";
import CopyIcon from "@src/assets/icons/Copy";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import SetupItem from "./SetupItem";
import DiffEditor from "@src/components/CodeEditor/DiffEditor";
import { name } from "@root/package.json";
import { useAppContext } from "@src/contexts/AppContext";
import { CONFIG } from "@src/config/dbPaths";
import { requiredRules, adminRules, utilFns } from "@src/config/firestoreRules";
import {
requiredRules,
adminRules,
utilFns,
insecureRule,
} from "@src/config/firestoreRules";
import { rowyRun } from "@src/utils/rowyRun";
import { runRoutes } from "@src/constants/runRoutes";
import { useConfirmation } from "@src/components/ConfirmationDialog";
// import { useConfirmation } from "@src/components/ConfirmationDialog";
export default function Step4Rules({
rowyRunUrl,
@@ -29,7 +36,7 @@ export default function Step4Rules({
setCompletion,
}: ISetupStepBodyProps) {
const { projectId, getAuthToken } = useAppContext();
const { requestConfirmation } = useConfirmation();
// const { requestConfirmation } = useConfirmation();
const [hasRules, setHasRules] = useState(completion.rules);
const [adminRule, setAdminRule] = useState(true);
@@ -52,6 +59,17 @@ export default function Step4Rules({
.then((data) => 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("");
useEffect(() => {
let rulesToInsert = rules;
@@ -63,13 +81,15 @@ export default function Step4Rules({
rulesToInsert = rulesToInsert.replace(/function hasAnyRole[^}]*}/s, "");
}
const inserted = currentRules.replace(
let inserted = currentRules.replace(
/match\s*\/databases\/\{database\}\/documents\s*\{/,
`match /databases/{database}/documents {\n` + rulesToInsert
);
if (hasInsecureRule) inserted = inserted.replace(insecureRuleRegExp, "");
setNewRules(inserted);
}, [currentRules, rules]);
}, [currentRules, rules, hasInsecureRule, insecureRuleRegExp]);
const [rulesStatus, setRulesStatus] = useState<"LOADING" | string>("");
const setRules = async () => {
@@ -97,18 +117,20 @@ export default function Step4Rules({
}
};
const handleSkip = () => {
requestConfirmation({
title: "Skip rules",
body: "This might prevent you or other users in your project from accessing firestore data on Rowy",
confirm: "Skip",
cancel: "cancel",
handleConfirm: async () => {
setCompletion((c) => ({ ...c, rules: true }));
setHasRules(true);
},
});
};
const [showManualMode, setShowManualMode] = useState(false);
// const handleSkip = () => {
// requestConfirmation({
// title: "Skip rules",
// body: "This might prevent you or other users in your project from accessing firestore data on Rowy",
// confirm: "Skip",
// cancel: "cancel",
// handleConfirm: async () => {
// setCompletion((c) => ({ ...c, rules: true }));
// setHasRules(true);
// },
// });
// };
return (
<>
@@ -139,88 +161,30 @@ export default function Step4Rules({
sx={{ "&&": { ml: -11 / 8, mb: -11 / 8 }, width: "100%" }}
/>
<Typography
variant="body2"
component="pre"
sx={{
width: { sm: "100%", md: 840 - 72 - 32 },
height: 136,
resize: "both",
overflow: "auto",
<Typography>
<InfoIcon
aria-label="Info"
sx={{ fontSize: 18, mr: 11 / 8, verticalAlign: "sub" }}
/>
We removed an insecure rule that allows anyone to access any part
of your database
</Typography>
"& .comment": { color: "info.dark" },
}}
dangerouslySetInnerHTML={{
__html: rules.replace(
/(\/\/.*$)/gm,
`<span class="comment">$1</span>`
),
}}
<DiffEditor
original={currentRules}
modified={newRules}
containerProps={{ sx: { width: "100%" } }}
minHeight={400}
options={{ renderValidationDecorations: "off" }}
/>
<Button
startIcon={<CopyIcon />}
onClick={() => navigator.clipboard.writeText(rules)}
<Typography
variant="inherit"
color={
rulesStatus !== "LOADING" && rulesStatus ? "error" : undefined
}
>
Copy to clipboard
</Button>
</>
)}
</SetupItem>
{!hasRules && (
<SetupItem
status="incomplete"
title={
<>
You can add these rules{" "}
<Link
href={`https://console.firebase.google.com/project/${
projectId || "_"
}/firestore/rules`}
target="_blank"
rel="noopener noreferrer"
>
in the Firebase Console
<InlineOpenInNewIcon />
</Link>{" "}
or directly below:
</>
}
>
<TextField
id="new-rules"
label="New rules"
value={newRules}
onChange={(e) => setNewRules(e.target.value)}
multiline
rows={5}
fullWidth
sx={{
"& .MuiInputBase-input": {
fontFamily: "mono",
letterSpacing: 0,
resize: "vertical",
},
}}
/>
<Typography
variant="inherit"
color={
rulesStatus !== "LOADING" && rulesStatus ? "error" : undefined
}
>
Please check the generated rules first.
</Typography>
<div
style={{
display: "flex",
justifyContent: "space-between",
}}
>
{" "}
Please verify the new rules first.
</Typography>
<LoadingButton
variant="contained"
color="primary"
@@ -229,13 +193,73 @@ export default function Step4Rules({
>
Set Firestore Rules
</LoadingButton>
<Button onClick={handleSkip}>Skip</Button>
{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>
{!hasRules && showManualMode && (
<SetupItem
status="incomplete"
title="Alternatively, you can add these rules in the Firebase Console."
>
<Typography
variant="caption"
component="pre"
sx={{
width: "100%",
height: 400,
resize: "both",
overflow: "auto",
"& .comment": { color: "info.dark" },
}}
dangerouslySetInnerHTML={{
__html: rules.replace(
/(\/\/.*$)/gm,
`<span class="comment">$1</span>`
),
}}
/>
<div>
<Grid container spacing={1}>
<Grid item>
<Button
startIcon={<CopyIcon />}
onClick={() => navigator.clipboard.writeText(rules)}
>
Copy to clipboard
</Button>
</Grid>
<Grid item>
<Button
href={`https://console.firebase.google.com/project/${
projectId || "_"
}/firestore/rules`}
target="_blank"
rel="noopener noreferrer"
>
Firebase Console
<InlineOpenInNewIcon />
</Button>
</Grid>
</Grid>
</div>
{rulesStatus !== "LOADING" && typeof rulesStatus === "string" && (
<Typography variant="caption" color="error">
{rulesStatus}
</Typography>
)}
</SetupItem>
)}
</>

View File

@@ -2,8 +2,9 @@ import { createElement, useEffect } from "react";
import { useForm } from "react-hook-form";
import _sortBy from "lodash/sortBy";
import _isEmpty from "lodash/isEmpty";
import createPersistedState from "use-persisted-state";
import { Stack } from "@mui/material";
import { Stack, FormControlLabel, Switch } from "@mui/material";
import { Values } from "./utils";
import { getFieldProp } from "@src/components/fields";
@@ -16,6 +17,10 @@ import { useAppContext } from "@src/contexts/AppContext";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { sanitizeFirestoreRefs } from "@src/utils/fns";
const useSideDrawerShowHiddenFieldsState = createPersistedState(
"__ROWY__SIDE_DRAWER_SHOW_HIDDEN_FIELDS"
);
export interface IFormProps {
values: Values;
}
@@ -23,12 +28,18 @@ export interface IFormProps {
export default function Form({ values }: IFormProps) {
const { tableState, sideDrawerRef } = useProjectContext();
const { userDoc } = useAppContext();
const userDocHiddenFields =
userDoc.state.doc?.tables?.[`${tableState!.tablePath}`]?.hiddenFields ?? [];
const fields = _sortBy(Object.values(tableState!.columns), "index").filter(
(f) => !userDocHiddenFields.includes(f.name)
);
const userDocHiddenFields =
userDoc.state.doc?.tables?.[`${tableState!.config.id}`]?.hiddenFields ?? [];
const [showHiddenFields, setShowHiddenFields] =
useSideDrawerShowHiddenFieldsState(false);
const fields = showHiddenFields
? _sortBy(Object.values(tableState!.columns), "index")
: _sortBy(Object.values(tableState!.columns), "index").filter(
(f) => !userDocHiddenFields.includes(f.key)
);
// Get initial values from fields config. This wont be written to the db
// when the SideDrawer is opened. Only dirty fields will be written
@@ -121,6 +132,24 @@ export default function Form({ values }: IFormProps) {
label="Document path"
debugText={values.ref?.path ?? values.id ?? "No ref"}
/>
{userDocHiddenFields.length > 0 && (
<FormControlLabel
label="Show hidden fields"
control={
<Switch
checked={showHiddenFields}
onChange={(e) => setShowHiddenFields(e.target.checked)}
/>
}
sx={{
borderTop: 1,
borderColor: "divider",
pt: 3,
"& .MuiSwitch-root": { ml: -0.5 },
}}
/>
)}
</Stack>
</form>
);

View File

@@ -70,7 +70,7 @@ export default function SideDrawer() {
if (cell && tableState?.rows[cell.row]) {
window.history.pushState(
"",
`${tableState?.tablePath}`,
`${tableState?.config.id}`,
`${window.location.pathname}?rowRef=${encodeURIComponent(
tableState?.rows[cell.row].ref.path
)}`

View File

@@ -147,7 +147,7 @@ export default function BulkActions({ selectedRows, columns, clearSelection }) {
},
column: actionColumn,
action: actionType,
schemaDocPath: formatPath(tableState?.tablePath ?? ""),
schemaDocPath: formatPath(tableState?.config.id ?? ""),
actionParams: {},
};
return true;

View File

@@ -22,11 +22,35 @@ export default function FieldSettings(props: IMenuModalProps) {
const [showRebuildPrompt, setShowRebuildPrompt] = useState(false);
const [newConfig, setNewConfig] = useState(config ?? {});
const customFieldSettings = getFieldProp("settings", type);
const settingsValidator = getFieldProp("settingsValidator", type);
const initializable = getFieldProp("initializable", type);
const { requestConfirmation } = useConfirmation();
const { tableState, rowyRun } = useProjectContext();
const rendedFieldSettings = useMemo(
() =>
[FieldType.derivative, FieldType.aggregate].includes(type) &&
newConfig.renderFieldType
? getFieldProp("settings", newConfig.renderFieldType)
: null,
[newConfig.renderFieldType, type]
);
const [errors, setErrors] = useState({});
if (!open) return null;
const validateSettings = () => {
if (settingsValidator) {
const errors = settingsValidator(newConfig);
setErrors(errors);
return errors;
}
setErrors({});
return {};
};
const handleChange = (key: string) => (update: any) => {
if (
showRebuildPrompt === false &&
@@ -37,16 +61,8 @@ export default function FieldSettings(props: IMenuModalProps) {
}
const updatedConfig = _set({ ...newConfig }, key, update);
setNewConfig(updatedConfig);
validateSettings();
};
const rendedFieldSettings = useMemo(
() =>
[FieldType.derivative, FieldType.aggregate].includes(type) &&
newConfig.renderFieldType
? getFieldProp("settings", newConfig.renderFieldType)
: null,
[newConfig.renderFieldType, type]
);
if (!open) return null;
return (
<Modal
@@ -78,8 +94,10 @@ export default function FieldSettings(props: IMenuModalProps) {
>
{createElement(customFieldSettings, {
config: newConfig,
handleChange,
onChange: handleChange,
fieldName,
onBlur: validateSettings,
errors,
})}
</Stack>
)}
@@ -94,7 +112,9 @@ export default function FieldSettings(props: IMenuModalProps) {
</Typography>
{createElement(rendedFieldSettings, {
config: newConfig,
handleChange,
onChange: handleChange,
onBlur: validateSettings,
errors,
})}
</Stack>
)}
@@ -111,6 +131,29 @@ export default function FieldSettings(props: IMenuModalProps) {
actions={{
primary: {
onClick: () => {
const errors = validateSettings();
if (Object.keys(errors).length > 0) {
requestConfirmation({
title: "Invalid settings",
customBody: (
<>
<Typography>Please fix the following settings:</Typography>
<ul style={{ paddingLeft: "1.5em" }}>
{Object.entries(errors).map(([key, message]) => (
<li key={key}>
<code>{key}</code>: {message}
</li>
))}
</ul>
</>
),
confirm: "Fix",
hideCancel: true,
handleConfirm: () => {},
});
return;
}
if (showRebuildPrompt) {
requestConfirmation({
title: "Deploy changes",

View File

@@ -11,6 +11,7 @@ export interface IFieldsDropdownProps {
hideLabel?: boolean;
label?: string;
options?: FieldType[];
[key: string]: any;
}
/**
@@ -22,6 +23,7 @@ export default function FieldsDropdown({
hideLabel = false,
label,
options: optionsProp,
...props
}: IFieldsDropdownProps) {
const options = optionsProp
? FIELDS.filter((fieldConfig) => optionsProp.indexOf(fieldConfig.type) > -1)
@@ -30,6 +32,7 @@ export default function FieldsDropdown({
return (
<MultiSelect
multiple={false}
{...props}
value={value ? value : ""}
onChange={onChange}
options={options.map((fieldConfig) => ({
@@ -54,6 +57,7 @@ export default function FieldsDropdown({
TextFieldProps={{
hiddenLabel: hideLabel,
helperText: value && getFieldProp("description", value),
...props.TextFieldProps,
SelectProps: {
displayEmpty: true,
renderValue: () => (
@@ -70,6 +74,7 @@ export default function FieldsDropdown({
{getFieldProp("name", value as FieldType)}
</>
),
...props.TextFieldProps?.SelectProps,
},
}}
/>

View File

@@ -39,15 +39,15 @@ export default function Filters() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
useEffect(() => {
if (userDoc.state.doc && tableState?.tablePath) {
if (userDoc.state.doc.tables?.[tableState?.tablePath]?.filters) {
if (userDoc.state.doc && tableState?.config.id) {
if (userDoc.state.doc.tables?.[tableState?.config.id]?.filters) {
tableActions?.table.filter(
userDoc.state.doc.tables[tableState?.tablePath].filters
userDoc.state.doc.tables[tableState?.config.id].filters
);
tableActions?.table.orderBy();
}
}
}, [userDoc.state, tableState?.tablePath]);
}, [userDoc.state, tableState?.config.id]);
const filterColumns = _sortBy(Object.values(tableState!.columns), "index")
.filter((c) => getFieldProp("filter", c.type))
@@ -100,7 +100,7 @@ export default function Filters() {
userDoc.dispatch({
action: DocActions.update,
data: {
tables: { [`${tableState?.tablePath}`]: { filters } },
tables: { [`${tableState?.config.id}`]: { filters } },
},
});
};
@@ -257,7 +257,7 @@ export default function Filters() {
control,
docRef: {},
disabled: false,
handleChange: () => {},
onChange: () => {},
})}
</Suspense>
</form>

View File

@@ -13,6 +13,7 @@ import { useProjectContext } from "@src/contexts/ProjectContext";
import { useAppContext } from "@src/contexts/AppContext";
import { DocActions } from "@src/hooks/useDoc";
import { formatSubTableName } from "../../utils/fns";
const useStyles = makeStyles((theme) =>
createStyles({
listbox: {},
@@ -69,7 +70,7 @@ export default function HiddenFields() {
// Initialise hiddenFields from user doc
const userDocHiddenFields =
userDoc.state.doc?.tables?.[formatSubTableName(tableState?.tablePath!)]
userDoc.state.doc?.tables?.[formatSubTableName(tableState?.config.id!)]
?.hiddenFields;
useEffect(() => {
if (userDocHiddenFields) setHiddenFields(userDocHiddenFields);
@@ -94,7 +95,7 @@ export default function HiddenFields() {
action: DocActions.update,
data: {
tables: {
[formatSubTableName(tableState?.tablePath)]: { hiddenFields },
[formatSubTableName(tableState?.config.id)]: { hiddenFields },
},
},
});

View File

@@ -64,7 +64,7 @@ export default function Export({ query, closeModal }) {
const [columns, setColumns] = useState<any[]>([]);
const [labelColumnsEnabled, setLabelColumnsEnabled] = useState(false);
const [labelColumns, setLabelColumns] = useState<any[]>([]);
const [packageName, setPackageName] = useState(tableState?.tablePath);
const [packageName, setPackageName] = useState(tableState?.config.id);
const handleClose = () => {
closeModal();

View File

@@ -126,7 +126,8 @@ export default function Export({ query, closeModal }) {
...doc.data(),
}));
const fileName = `${tableState?.tablePath!}-${new Date().toISOString()}.${exportType}`;
const fileName = `${tableState?.config
.id!}-${new Date().toISOString()}.${exportType}`;
switch (exportType) {
case "csv":
const csvData = docs.map((doc: any) =>

View File

@@ -38,7 +38,7 @@ export type TableColumn = Column<any> & {
};
const rowKeyGetter = (row: any) => row.id;
const SelectColumn = { ..._SelectColumn, width: 42, maxWidth: 42 };
// const SelectColumn = { ..._SelectColumn, width: 42, maxWidth: 42 };
export default function Table() {
const classes = useStyles();
@@ -48,7 +48,7 @@ export default function Table() {
const { userDoc } = useAppContext();
const userDocHiddenFields =
userDoc.state.doc?.tables?.[formatSubTableName(tableState?.tablePath)]
userDoc.state.doc?.tables?.[formatSubTableName(tableState?.config.id)]
?.hiddenFields ?? [];
const [columns, setColumns] = useState<TableColumn[]>([]);

View File

@@ -34,7 +34,7 @@ export const tableSettings = (
label: "Table ID",
required: true,
watchedField: "name",
assistiveText: `Unique ID for this table used to store configuration. Cannot be edited ${
assistiveText: `Unique ID for this table used to store configuration. Cannot be edited${
mode === TableSettingsDialogModes.create ? " later" : ""
}.`,
disabled: mode === TableSettingsDialogModes.update,
@@ -55,6 +55,7 @@ export const tableSettings = (
<>
<WarningIcon
color="warning"
aria-label="Warning"
sx={{ fontSize: 16, mr: 0.5, verticalAlign: "middle" }}
/>
You change which Firestore collection to display. Data in the new

View File

@@ -54,7 +54,6 @@ export default function ActionFab({
const { tableState, rowyRun } = useProjectContext();
const { ref } = row;
const { config } = column as any;
const action = !value
? "run"
: value.undo
@@ -71,7 +70,7 @@ export default function ActionFab({
ref: { path: ref.path },
column: { ...column, editor: undefined },
action,
schemaDocPath: formatPath(tableState?.tablePath ?? ""),
schemaDocPath: tableState?.config.tableConfig.path,
actionParams,
});

View File

@@ -18,7 +18,7 @@ const CodeEditor = lazy(
import("@src/components/CodeEditor" /* webpackChunkName: "CodeEditor" */)
);
const Settings = ({ config, handleChange }) => {
const Settings = ({ config, onChange }) => {
const { tableState, roles } = useProjectContext();
const columnOptions = Object.values(tableState?.columns ?? {}).map((c) => ({
label: c.name,
@@ -34,7 +34,7 @@ const Settings = ({ config, handleChange }) => {
label="Allowed roles"
options={roles ?? []}
value={config.requiredRoles ?? []}
onChange={handleChange("requiredRoles")}
onChange={onChange("requiredRoles")}
/>
<Typography variant="overline">Required fields</Typography>
@@ -46,7 +46,7 @@ const Settings = ({ config, handleChange }) => {
label="Required fields"
options={columnOptions}
value={config.requiredFields ?? []}
onChange={handleChange("requiredFields")}
onChange={onChange("requiredFields")}
/>
<Divider />
<Typography variant="overline">Confirmation template</Typography>
@@ -59,7 +59,7 @@ const Settings = ({ config, handleChange }) => {
placeholder="Are sure you want to invest {{stockName}}?"
value={config.confirmation}
onChange={(e) => {
handleChange("confirmation")(e.target.value);
onChange("confirmation")(e.target.value);
}}
fullWidth
/>
@@ -68,16 +68,12 @@ const Settings = ({ config, handleChange }) => {
<Switch
checked={config.isActionScript}
onChange={() =>
handleChange("isActionScript")(!Boolean(config.isActionScript))
onChange("isActionScript")(!Boolean(config.isActionScript))
}
name="actionScript"
/>
}
label="Set as an action script"
sx={{
alignItems: "center",
"& .MuiFormControlLabel-label": { mt: 0 },
}}
/>
{!Boolean(config.isActionScript) ? (
<TextField
@@ -86,7 +82,7 @@ const Settings = ({ config, handleChange }) => {
value={config.callableName}
fullWidth
onChange={(e) => {
handleChange("callableName")(e.target.value);
onChange("callableName")(e.target.value);
}}
/>
) : (
@@ -129,7 +125,7 @@ const Settings = ({ config, handleChange }) => {
"}",
].join("\n"),
]}
onChange={handleChange("script")}
onChange={onChange("script")}
/>
</Suspense>
<FormControlLabel
@@ -137,32 +133,24 @@ const Settings = ({ config, handleChange }) => {
<Switch
checked={config.redo?.enabled}
onChange={() =>
handleChange("redo.enabled")(!Boolean(config.redo?.enabled))
onChange("redo.enabled")(!Boolean(config.redo?.enabled))
}
name="redo toggle"
/>
}
label="User can redo (re-runs the same script)"
sx={{
alignItems: "center",
"& .MuiFormControlLabel-label": { mt: 0 },
}}
/>
<FormControlLabel
control={
<Switch
checked={config.undo?.enabled}
onChange={() =>
handleChange("undo.enabled")(!Boolean(config.undo?.enabled))
onChange("undo.enabled")(!Boolean(config.undo?.enabled))
}
name="undo toggle"
/>
}
label="User can undo"
sx={{
alignItems: "center",
"& .MuiFormControlLabel-label": { mt: 0 },
}}
/>
{config["undo.enabled"] && (
<>
@@ -174,7 +162,7 @@ const Settings = ({ config, handleChange }) => {
placeholder="are you sure you want to sell your stocks in {{stockName}}"
value={config["undo.confirmation"]}
onChange={(e) => {
handleChange("undo.confirmation")(e.target.value);
onChange("undo.confirmation")(e.target.value);
}}
fullWidth
/>
@@ -183,7 +171,7 @@ const Settings = ({ config, handleChange }) => {
<CodeEditor
minHeight={300}
value={config["undo.script"]}
onChange={handleChange("undo.script")}
onChange={onChange("undo.script")}
/>
</Suspense>
</>

View File

@@ -10,7 +10,7 @@ const CodeEditor = lazy(
import("@src/components/CodeEditor" /* webpackChunkName: "CodeEditor" */)
);
const Settings = ({ config, handleChange }) => {
const Settings = ({ config, onChange }) => {
const { tableState } = useProjectContext();
const columnOptions = Object.values(tableState?.columns ?? {})
@@ -22,7 +22,7 @@ const Settings = ({ config, handleChange }) => {
label="Sub-tables"
options={columnOptions}
value={config.requiredFields ?? []}
onChange={handleChange("subtables")}
onChange={onChange("subtables")}
/>
<Typography variant="overline">Aggergate script</Typography>
<Suspense fallback={<FieldSkeleton height={200} />}>
@@ -49,7 +49,7 @@ switch (triggerType){
}`,
]}
onChange={handleChange("script")}
onChange={onChange("script")}
/>
</Suspense>
@@ -66,7 +66,7 @@ switch (triggerType){
].includes(f)
)}
onChange={(value) => {
handleChange("renderFieldType")(value);
onChange("renderFieldType")(value);
}}
/>
</>

View File

@@ -36,13 +36,13 @@ export const useStyles = makeStyles((theme) =>
display: "block",
height: 16,
background: `linear-gradient(to bottom, #fff, rgba(255, 255, 255, 0))`,
background: `linear-gradient(to bottom, ${theme.palette.background.paper}, rgba(255, 255, 255, 0))`,
},
"&::after": {
top: "auto",
bottom: 0,
background: `linear-gradient(to top, #fff, rgba(255, 255, 255, 0))`,
background: `linear-gradient(to top, ${theme.palette.background.paper}, rgba(255, 255, 255, 0))`,
},
},
list: () => {

View File

@@ -1,6 +1,6 @@
import { TextField, FormControlLabel, Switch } from "@mui/material";
import { TextField, FormControlLabel, Switch, Grid } from "@mui/material";
export default function Settings({ config, handleChange }) {
export default function Settings({ config, onChange }) {
return (
<>
<TextField
@@ -9,7 +9,7 @@ export default function Settings({ config, handleChange }) {
value={config.url}
fullWidth
onChange={(e) => {
handleChange("url")(e.target.value);
onChange("url")(e.target.value);
}}
/>
<TextField
@@ -20,7 +20,7 @@ export default function Settings({ config, handleChange }) {
value={config.resultsKey}
fullWidth
onChange={(e) => {
handleChange("resultsKey")(e.target.value);
onChange("resultsKey")(e.target.value);
}}
/>
<TextField
@@ -29,32 +29,38 @@ export default function Settings({ config, handleChange }) {
value={config.primaryKey}
fullWidth
onChange={(e) => {
handleChange("primaryKey")(e.target.value);
}}
/>
<TextField
label="Title key (optional)"
name="titleKey"
value={config.titleKey}
fullWidth
onChange={(e) => {
handleChange("titleKey")(e.target.value);
}}
/>
<TextField
label="Subtitle key (optional)"
name="subtitleKey"
value={config.subtitleKey}
fullWidth
onChange={(e) => {
handleChange("subtitleKey")(e.target.value);
onChange("primaryKey")(e.target.value);
}}
/>
<Grid container direction="row" spacing={1}>
<Grid item xs={12} md={6}>
<TextField
label="Title key (optional)"
name="titleKey"
value={config.titleKey}
fullWidth
onChange={(e) => {
onChange("titleKey")(e.target.value);
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
label="Subtitle key (optional)"
name="subtitleKey"
value={config.subtitleKey}
fullWidth
onChange={(e) => {
onChange("subtitleKey")(e.target.value);
}}
/>{" "}
</Grid>{" "}
</Grid>
<FormControlLabel
control={
<Switch
checked={config.multiple}
onChange={() => handleChange("multiple")(!Boolean(config.multiple))}
onChange={() => onChange("multiple")(!Boolean(config.multiple))}
name="select-multiple"
/>
}

View File

@@ -36,6 +36,7 @@ export const config: IFieldConfig = {
}),
TableEditor: NullEditor as any,
SideDrawerField,
requireConfiguration: true,
settings: Settings,
};
export default config;

View File

@@ -20,7 +20,7 @@ import { useProjectContext } from "@src/contexts/ProjectContext";
import { TABLE_SCHEMAS } from "@src/config/dbPaths";
import { WIKI_LINKS } from "@src/constants/externalLinks";
export default function Settings({ handleChange, config }: ISettingsProps) {
export default function Settings({ onChange, config }: ISettingsProps) {
const { tables } = useProjectContext();
const tableOptions = _sortBy(
tables?.map((table) => ({
@@ -71,7 +71,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
options={tableOptions}
freeText={false}
value={config.index}
onChange={handleChange("index")}
onChange={onChange("index")}
multiple={false}
label="Table"
labelPlural="tables"
@@ -91,7 +91,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
control={
<Checkbox
checked={config.multiple !== false}
onChange={(e) => handleChange("multiple")(e.target.checked)}
onChange={(e) => onChange("multiple")(e.target.checked)}
/>
}
label={
@@ -137,7 +137,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
name="filters"
fullWidth
value={config.filters}
onChange={(e) => handleChange("filters")(e.target.value)}
onChange={(e) => onChange("filters")(e.target.value)}
placeholder="attribute:value AND | OR | NOT attribute:value"
id="connectTable-filters"
helperText={
@@ -161,7 +161,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
options={columns.filter((c) =>
[FieldType.shortText, FieldType.singleSelect].includes(c.type)
)}
onChange={handleChange("primaryKeys")}
onChange={onChange("primaryKeys")}
TextFieldProps={{ helperText: "Field values displayed" }}
/>
@@ -169,7 +169,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
label="Snapshot fields"
value={config.snapshotFields ?? []}
options={columns.filter((c) => ![FieldType.subTable].includes(c.type))}
onChange={handleChange("snapshotFields")}
onChange={onChange("snapshotFields")}
TextFieldProps={{ helperText: "Fields stored in the snapshots" }}
/>
@@ -177,7 +177,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
label="Tracked fields"
value={config.trackedFields ?? []}
options={columns.filter((c) => ![FieldType.subTable].includes(c.type))}
onChange={handleChange("trackedFields")}
onChange={onChange("trackedFields")}
TextFieldProps={{
helperText:
"Fields to be tracked for changes and synced to the snapshot",

View File

@@ -7,7 +7,7 @@ import MultiSelect from "@rowy/multiselect";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import { EXTERNAL_LINKS } from "@src/constants/externalLinks";
export default function Settings({ handleChange, config }: ISettingsProps) {
export default function Settings({ onChange, config }: ISettingsProps) {
return (
<>
<MultiSelect
@@ -27,7 +27,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
clearable={false}
searchable={false}
value={config.format ?? DATE_TIME_FORMAT}
onChange={handleChange("format")}
onChange={onChange("format")}
TextFieldProps={{
helperText: (
<Link

View File

@@ -7,7 +7,7 @@ import MultiSelect from "@rowy/multiselect";
import { DATE_FORMAT } from "@src/constants/dates";
import { EXTERNAL_LINKS } from "@src/constants/externalLinks";
export default function Settings({ handleChange, config }: ISettingsProps) {
export default function Settings({ onChange, config }: ISettingsProps) {
return (
<>
<MultiSelect
@@ -21,7 +21,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
clearable={false}
searchable={false}
value={config.format ?? DATE_FORMAT}
onChange={handleChange("format")}
onChange={onChange("format")}
TextFieldProps={{
helperText: (
<Link

View File

@@ -7,7 +7,7 @@ import MultiSelect from "@rowy/multiselect";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import { EXTERNAL_LINKS } from "@src/constants/externalLinks";
export default function Settings({ handleChange, config }: ISettingsProps) {
export default function Settings({ onChange, config }: ISettingsProps) {
return (
<>
<MultiSelect
@@ -27,7 +27,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
clearable={false}
searchable={false}
value={config.format ?? DATE_TIME_FORMAT}
onChange={handleChange("format")}
onChange={onChange("format")}
TextFieldProps={{
helperText: (
<Link

View File

@@ -1,7 +1,7 @@
import { lazy, Suspense } from "react";
import { ISettingsProps } from "../types";
import { Grid, InputLabel } from "@mui/material";
import { Grid, InputLabel, FormHelperText } from "@mui/material";
import MultiSelect from "@rowy/multiselect";
import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton";
import FieldsDropdown from "@src/components/Table/ColumnMenu/FieldsDropdown";
@@ -18,11 +18,14 @@ const CodeEditor = lazy(
export default function Settings({
config,
handleChange,
onChange,
fieldName,
onBlur,
errors,
}: ISettingsProps) {
const { tableState } = useProjectContext();
if (!tableState?.columns) return <></>;
const columnOptions = Object.values(tableState.columns)
.filter((column) => column.fieldName !== fieldName)
.filter((column) => column.type !== FieldType.subTable)
@@ -36,10 +39,25 @@ export default function Settings({
label="Listener fields"
options={columnOptions}
value={config.listenerFields ?? []}
onChange={handleChange("listenerFields")}
onChange={onChange("listenerFields")}
TextFieldProps={{
helperText:
"Changes to these fields will trigger the evaluation of the column.",
helperText: (
<>
{errors.listenerFields && (
<FormHelperText error style={{ margin: 0 }}>
{errors.listenerFields}
</FormHelperText>
)}
<FormHelperText error={false} style={{ margin: 0 }}>
Changes to these fields will trigger the evaluation of the
column.
</FormHelperText>
</>
),
FormHelperTextProps: { component: "div" } as any,
required: true,
error: errors.listenerFields,
onBlur,
}}
/>
</Grid>
@@ -58,7 +76,13 @@ export default function Settings({
].includes(f)
)}
onChange={(value) => {
handleChange("renderFieldType")(value);
onChange("renderFieldType")(value);
}}
TextFieldProps={{
required: true,
error: errors.renderFieldType,
helperText: errors.renderFieldType,
onBlur,
}}
/>
</Grid>
@@ -68,9 +92,16 @@ export default function Settings({
<InputLabel>Derivative script</InputLabel>
<CodeEditorHelper docLink={WIKI_LINKS.fieldTypesDerivative} />
<Suspense fallback={<FieldSkeleton height={200} />}>
<CodeEditor value={config.script} onChange={handleChange("script")} />
<CodeEditor value={config.script} onChange={onChange("script")} />
</Suspense>
</div>
</>
);
}
export const settingsValidator = (config) => {
const errors: Record<string, any> = {};
if (!config.listenerFields) errors.listenerFields = "Required";
if (!config.renderFieldType) errors.renderFieldType = "Required";
return errors;
};

View File

@@ -4,7 +4,7 @@ import withBasicCell from "../_withTableCell/withBasicCell";
import DerivativeIcon from "@src/assets/icons/Derivative";
import BasicCell from "../_BasicCell/BasicCellNull";
import NullEditor from "@src/components/Table/editors/NullEditor";
import Settings from "./Settings";
import Settings, { settingsValidator } from "./Settings";
export const config: IFieldConfig = {
type: FieldType.derivative,
@@ -21,5 +21,6 @@ export const config: IFieldConfig = {
TableEditor: NullEditor as any,
SideDrawerField: BasicCell as any,
settings: Settings,
settingsValidator,
};
export default config;

View File

@@ -1,13 +1,13 @@
import { Checkbox, FormControlLabel, FormHelperText } from "@mui/material";
const Settings = ({ config, handleChange }) => {
const Settings = ({ config, onChange }) => {
return (
<>
<FormControlLabel
control={
<Checkbox
checked={config.isArray}
onChange={() => handleChange("isArray")(!Boolean(config.isArray))}
onChange={() => onChange("isArray")(!Boolean(config.isArray))}
name="isArray"
/>
}

View File

@@ -3,7 +3,7 @@ import { ISettingsProps } from "../types";
import { Slider } from "@mui/material";
import Subheading from "@src/components/Table/ColumnMenu/Subheading";
export default function Settings({ handleChange, config }: ISettingsProps) {
export default function Settings({ onChange, config }: ISettingsProps) {
return (
<>
<Subheading>Maximum number of stars</Subheading>
@@ -14,7 +14,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
aria-labelledby="max-slider"
valueLabelDisplay="auto"
onChange={(_, v) => {
handleChange("max")(v);
onChange("max")(v);
}}
step={1}
marks
@@ -29,7 +29,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
aria-labelledby="precision-slider"
valueLabelDisplay="auto"
onChange={(_, v) => {
handleChange("precision")(v);
onChange("precision")(v);
}}
step={0.25}
marks

View File

@@ -1,6 +1,6 @@
import { TextField } from "@mui/material";
export default function Settings({ handleChange, config }) {
export default function Settings({ onChange, config }) {
return (
<>
<TextField
@@ -10,8 +10,8 @@ export default function Settings({ handleChange, config }) {
value={config.maxLength}
fullWidth
onChange={(e) => {
if (e.target.value === "0") handleChange("maxLength")(null);
else handleChange("maxLength")(e.target.value);
if (e.target.value === "0") onChange("maxLength")(null);
else onChange("maxLength")(e.target.value);
}}
/>
<TextField
@@ -21,8 +21,8 @@ export default function Settings({ handleChange, config }) {
value={config.validationRegex}
fullWidth
onChange={(e) => {
if (e.target.value === "") handleChange("validationRegex")(null);
else handleChange("validationRegex")(e.target.value);
if (e.target.value === "") onChange("validationRegex")(null);
else onChange("validationRegex")(e.target.value);
}}
/>
</>

View File

@@ -29,14 +29,14 @@ const useStyles = makeStyles(() =>
})
);
export default function Settings({ handleChange, config }) {
export default function Settings({ onChange, config }) {
const listEndRef: any = useRef(null);
const options = config.options ?? [];
const classes = useStyles();
const [newOption, setNewOption] = useState("");
const handleAdd = () => {
if (newOption.trim() !== "") {
handleChange("options")([...options, newOption.trim()]);
onChange("options")([...options, newOption.trim()]);
setNewOption("");
listEndRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
}
@@ -62,7 +62,7 @@ export default function Settings({ handleChange, config }) {
<IconButton
aria-label="Remove"
onClick={() =>
handleChange("options")(
onChange("options")(
options.filter((o: string) => o !== option)
)
}
@@ -112,7 +112,7 @@ export default function Settings({ handleChange, config }) {
control={
<Checkbox
checked={config.freeText}
onChange={(e) => handleChange("freeText")(e.target.checked)}
onChange={(e) => onChange("freeText")(e.target.checked)}
/>
}
label={

View File

@@ -1,7 +1,7 @@
import { TextField, FormControlLabel, Switch } from "@mui/material";
import Subheading from "@src/components/Table/ColumnMenu/Subheading";
export default function Settings({ handleChange, config }) {
export default function Settings({ onChange, config }) {
return (
<>
<Subheading>Slider config</Subheading>
@@ -10,7 +10,7 @@ export default function Settings({ handleChange, config }) {
variant="filled"
fullWidth
margin="none"
onChange={(e) => handleChange("min")(parseFloat(e.target.value))}
onChange={(e) => onChange("min")(parseFloat(e.target.value))}
value={config["min"]}
id={`settings-field-min`}
label="Minimum value"
@@ -21,7 +21,7 @@ export default function Settings({ handleChange, config }) {
variant="filled"
fullWidth
margin="none"
onChange={(e) => handleChange("max")(parseFloat(e.target.value))}
onChange={(e) => onChange("max")(parseFloat(e.target.value))}
value={config["max"]}
id={`settings-field-max`}
label="Maximum value"
@@ -32,7 +32,7 @@ export default function Settings({ handleChange, config }) {
variant="filled"
fullWidth
margin="none"
onChange={(e) => handleChange("step")(parseFloat(e.target.value))}
onChange={(e) => onChange("step")(parseFloat(e.target.value))}
value={config["step"]}
id={`settings-field-step`}
label="Step value"
@@ -43,15 +43,11 @@ export default function Settings({ handleChange, config }) {
control={
<Switch
checked={config.marks}
onChange={() => handleChange("marks")(!Boolean(config.marks))}
onChange={() => onChange("marks")(!Boolean(config.marks))}
name="marks"
/>
}
label="Show slider steps"
sx={{
alignItems: "center",
"& .MuiFormControlLabel-label": { mt: 0 },
}}
/>
</>
);

View File

@@ -172,7 +172,7 @@ const ConditionModal = ({ modal, setModal, conditions, setConditions }) => {
);
};
export default function Settings({ handleChange, config }: ISettingsProps) {
export default function Settings({ onChange, config }: ISettingsProps) {
const [modal, setModal] = useState(EMPTY_STATE);
const { conditions } = config;
return (
@@ -228,7 +228,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
modal={modal}
setModal={setModal}
conditions={config.conditions}
setConditions={handleChange("conditions")}
setConditions={onChange("conditions")}
/>
</>
);

View File

@@ -2,7 +2,7 @@ import MultiSelect from "@rowy/multiselect";
import { FieldType } from "@src/constants/fields";
import { useProjectContext } from "@src/contexts/ProjectContext";
const Settings = ({ config, handleChange }) => {
const Settings = ({ config, onChange }) => {
const { tableState } = useProjectContext();
if (!tableState?.columns) return <></>;
const columnOptions = Object.values(tableState.columns)
@@ -21,7 +21,7 @@ const Settings = ({ config, handleChange }) => {
label="Parent label"
options={columnOptions}
value={config.parentLabel ?? []}
onChange={handleChange("parentLabel")}
onChange={onChange("parentLabel")}
/>
</>
);

View File

@@ -21,6 +21,7 @@ export interface IFieldConfig {
TableEditor: React.ComponentType<EditorProps<any, any>>;
SideDrawerField: React.ComponentType<ISideDrawerFieldProps>;
settings?: React.ComponentType<ISettingsProps>;
settingsValidator?: (config: Record<string, any>) => Record<string, string>;
filter?: {
operators: IFilterOperator[];
customInput?: React.ComponentType<IFiltersProps>;
@@ -59,14 +60,16 @@ export interface ISideDrawerFieldProps {
}
export interface ISettingsProps {
handleChange: (key: string) => (value: any) => void;
onChange: (key: string) => (value: any) => void;
config: Record<string, any>;
fieldName: string;
onBlur: React.FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
errors: Record<string, any>;
}
// TODO: WRITE TYPES
export interface IFiltersProps {
handleChange: (key: string) => (value: any) => void;
onChange: (key: string) => (value: any) => void;
[key: string]: any;
}

View File

@@ -37,3 +37,9 @@ export const utilFns = `
return request.auth != null && request.auth.token.roles.hasAny(roles);
}
` as const;
export const insecureRule = `
match /{document=**} {
allow read, write: if true;
}
` as const;

View File

@@ -60,6 +60,8 @@ export interface ISetupStepBodyProps {
rowyRunUrl: string;
}
const BASE_WIDTH = 1024;
const checkAllSteps = async (
rowyRunUrl: string,
currentUser: firebase.default.User | null | undefined,
@@ -256,7 +258,7 @@ export default function SetupPage() {
alpha(theme.palette.background.paper, 0.5),
backdropFilter: "blur(20px) saturate(150%)",
maxWidth: 840,
maxWidth: BASE_WIDTH,
width: (theme) => `calc(100vw - ${theme.spacing(2)})`,
maxHeight: (theme) =>
`calc(${
@@ -264,7 +266,7 @@ export default function SetupPage() {
} - ${theme.spacing(
2
)} - env(safe-area-inset-top) - env(safe-area-inset-bottom))`,
height: 840 * 0.75,
height: BASE_WIDTH * 0.75,
resize: "both",
p: 0,

View File

@@ -768,14 +768,7 @@ export default function TestView() {
</Stack>
<div>
<FormControlLabel
control={<Switch />}
label="Label"
sx={{
alignItems: "center",
"& .MuiFormControlLabel-label": { mt: 0 },
}}
/>
<FormControlLabel control={<Switch />} label="Label" />
<FormControlLabel
control={<Switch size="medium" />}
label="Label"

View File

@@ -792,7 +792,13 @@ export const components = (theme: Theme): ThemeOptions => {
root: {
display: "flex",
alignItems: "flex-start",
"& .MuiSwitch-root": { marginRight: theme.spacing(1) },
"& .MuiSwitch-root": {
marginRight: theme.spacing(1),
"&.MuiSwitch-sizeSmall + .MuiFormControlLabel-label": {
marginTop: 4,
},
},
},
label: {
marginTop: 10,