From 25570b4b5ef0989094b71f615d9792d28b2034f6 Mon Sep 17 00:00:00 2001 From: Sidney Alcantara Date: Wed, 29 Sep 2021 17:20:30 +1000 Subject: [PATCH] new table settings dialog --- package.json | 5 +- src/components/TableSettings/CamelCaseId.tsx | 70 +++++ .../TableSettings/SuggestedRules.tsx | 140 ++++++++++ src/components/TableSettings/form.tsx | 258 +++++++++++------- src/components/TableSettings/index.tsx | 173 ++++-------- src/contexts/ProjectContext.tsx | 2 +- yarn.lock | 28 +- 7 files changed, 451 insertions(+), 225 deletions(-) create mode 100644 src/components/TableSettings/CamelCaseId.tsx create mode 100644 src/components/TableSettings/SuggestedRules.tsx diff --git a/package.json b/package.json index 79cea7ee..bb465e58 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "@mui/lab": "^5.0.0-alpha.47", "@mui/material": "^5.0.0", "@mui/styles": "^5.0.0", - "@rowy/form-builder": "^0.2.0", - "@rowy/multiselect": "^0.2.0", + "@rowy/form-builder": "^0.2.4", + "@rowy/multiselect": "^0.2.1", "@tinymce/tinymce-react": "^3.12.6", "algoliasearch": "^4.8.6", "ansi-to-react": "^6.1.5", @@ -62,6 +62,7 @@ "react-scroll-sync": "^0.8.0", "react-usestateref": "^1.0.5", "serve": "^11.3.2", + "swr": "^1.0.1", "tinymce": "^5.9.2", "typescript": "^4.4.2", "use-algolia": "^1.4.1", diff --git a/src/components/TableSettings/CamelCaseId.tsx b/src/components/TableSettings/CamelCaseId.tsx new file mode 100644 index 00000000..fe69f6ec --- /dev/null +++ b/src/components/TableSettings/CamelCaseId.tsx @@ -0,0 +1,70 @@ +import { useEffect } from "react"; +import { useWatch } from "react-hook-form"; +import _camelCase from "lodash/camelCase"; + +import { TextField, TextFieldProps } from "@mui/material"; +import { IFieldComponentProps, FieldAssistiveText } from "@rowy/form-builder"; + +export interface ICamelCaseIdProps + extends IFieldComponentProps, + Omit< + TextFieldProps, + "variant" | "name" | "label" | "onBlur" | "onChange" | "value" | "ref" + > { + watchedField?: string; +} + +export default function CamelCaseId({ + field: { onChange, onBlur, value, ref }, + + name, + useFormMethods: { control }, + + errorMessage, + assistiveText, + + disabled, + + watchedField, + ...props +}: ICamelCaseIdProps) { + const watchedValue = useWatch({ control, name: watchedField } as any); + useEffect(() => { + if (watchedField && typeof watchedValue === "string" && !!watchedValue) + onChange(_camelCase(watchedValue)); + }, [watchedValue]); + + return ( + + {errorMessage} + + + {assistiveText} + + + ) + } + name={name} + id={`field-${name}`} + sx={{ "& .MuiInputBase-input": { fontFamily: "mono" } }} + {...props} + disabled={disabled} + inputProps={{ + required: false, + // https://github.com/react-hook-form/react-hook-form/issues/4485 + disabled: false, + readOnly: disabled, + style: disabled ? { cursor: "default" } : undefined, + }} + inputRef={ref} + /> + ); +} diff --git a/src/components/TableSettings/SuggestedRules.tsx b/src/components/TableSettings/SuggestedRules.tsx new file mode 100644 index 00000000..d2bbe742 --- /dev/null +++ b/src/components/TableSettings/SuggestedRules.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { useWatch } from "react-hook-form"; + +import { + InputLabel, + Collapse, + FormControlLabel, + Checkbox, + Grid, + Button, +} from "@mui/material"; +import InlineOpenInNewIcon from "components/InlineOpenInNewIcon"; +import { IFieldComponentProps } from "@rowy/form-builder"; + +import { useAppContext } from "@src/contexts/AppContext"; + +type customizationOptions = "allRead" | "authRead" | "subcollections" | "user"; + +export interface ISuggestedRulesProps extends IFieldComponentProps {} + +export default function SuggestedRules({ + useFormMethods: { control }, + label, +}: ISuggestedRulesProps) { + const { projectId } = useAppContext(); + + const watched = useWatch({ control, name: ["collection", "roles"] } as any); + const [collection, roles] = Array.isArray(watched) ? watched : []; + + const [customized, setCustomized] = useState(false); + const [customizations, setCustomizations] = useState( + [] + ); + const handleChange = + (option: customizationOptions) => + (e: React.ChangeEvent) => + setCustomizations((prev) => { + const set = new Set(prev || []); + if (e.target.checked) set.add(option); + else set.delete(option); + return Array.from(set); + }); + + const generatedRules = `match /${collection}/{${ + customizations.includes("subcollections") ? "document=**" : "docId" + }} { + allow read, write: if hasAnyRole(${JSON.stringify(roles)});${ + customizations.includes("allRead") + ? "\n allow read: if true;" + : customizations.includes("authRead") + ? "\n allow read: if request.auth != null;" + : "" + }${ + customizations.includes("user") + ? `\n + allow create: if request.auth != null; + allow get, update, delete: if isDocOwner(userId);` + : "" + } +}`; + + return ( + <> + {label} +
{generatedRules}
+ + + + + + } + label="Anyone can read" + /> + + + + } + label="All signed-in users can read" + /> + + + + } + label="Users can create and edit docs" + /> + + + + } + label="Same rules for all subcollections" + /> + + + + + + {!customized && ( + + + + )} + + + + + + + + + ); +} diff --git a/src/components/TableSettings/form.tsx b/src/components/TableSettings/form.tsx index 97ffceb4..1761dfe0 100644 --- a/src/components/TableSettings/form.tsx +++ b/src/components/TableSettings/form.tsx @@ -2,7 +2,8 @@ import { Field, FieldType } from "@rowy/form-builder"; import { TableSettingsDialogModes } from "./index"; import { Link, Typography } from "@mui/material"; -import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import OpenInNewIcon from "components/InlineOpenInNewIcon"; +import WarningIcon from "@mui/icons-material/WarningAmber"; import { WIKI_LINKS } from "constants/externalLinks"; import { name } from "@root/package.json"; @@ -11,7 +12,8 @@ export const tableSettings = ( mode: TableSettingsDialogModes | null, roles: string[] | undefined, sections: string[] | undefined, - tables: { label: string; value: any }[] | undefined + tables: { label: string; value: any }[] | undefined, + collections: string[] ): Field[] => [ { @@ -19,126 +21,205 @@ export const tableSettings = ( name: "name", label: "Table name", required: true, + assistiveText: "User-facing name for this table", + autoFocus: true, + gridCols: { xs: 12, sm: 6 }, }, { - type: FieldType.shortText, + 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 }, }, { - type: FieldType.shortText, + type: FieldType.singleSelect, name: "collection", - label: "Collection name", + label: "Collection", + labelPlural: "collections", + options: collections, + itemRenderer: (option) => {option.label}, + freeText: true, required: true, assistiveText: ( - - View your Firestore collections - - - ) as any, + <> + {mode === TableSettingsDialogModes.update ? ( + <> + + You change which Firestore collection to display. Data in the new + collection must be compatible with the existing columns. + + ) : ( + "Choose which Firestore collection to display." + )}{" "} + + Your collections + + + + ), + AddButtonProps: { + children: "Add collection", + }, + AddDialogProps: { + title: "Add collection", + textFieldLabel: ( + <> + Collection name + + (Collection won’t be created until you add a row) + + + ), + }, + TextFieldProps: { + sx: { "& .MuiInputBase-input": { fontFamily: "mono" } }, + }, + gridCols: { xs: 12, sm: 6 }, }, { type: FieldType.singleSelect, name: "tableType", label: "Table type", - labelPlural: "table types", - searchable: false, defaultValue: "primaryCollection", options: [ { - label: "Primary collection", - description: ` - Connect this table to the single collection - matching the collection name entered above`, + label: ( +
+ Primary collection + + Connect this table to the single collection matching the + collection name entered above + +
+ ), value: "primaryCollection", }, { - label: "Collection group", - description: ` - Connect this table to all collections and subcollections - matching the collection name entered above`, + label: ( +
+ Collection group + + Connect this table to all collections and subcollections{" "} + matching the collection name entered above + +
+ ), value: "collectionGroup", }, ], required: true, disabled: mode === TableSettingsDialogModes.update, - itemRenderer: (option) => ( - - {option.label} - - - ), assistiveText: ( - - Learn more about collection groups - - - ) as any, + <> + Cannot be edited + {mode === TableSettingsDialogModes.create && " later"}.{" "} + + Learn more about collection groups + + + + ), + gridCols: { xs: 12, sm: 6 }, + }, + + { + type: FieldType.contentSubHeader, + name: "_contentSubHeader_userFacing", + label: "Display", }, { type: FieldType.singleSelect, name: "section", - label: "Section", + label: "Section (optional)", + labelPlural: "sections", freeText: true, options: sections, - required: true, + required: false, + gridCols: { xs: 12, sm: 6 }, }, { type: FieldType.paragraph, name: "description", - label: "Description", + label: "Description (optional)", + gridCols: { xs: 12, sm: 6 }, + minRows: 1, + }, + + { + type: FieldType.contentSubHeader, + name: "_contentSubHeader_admin", + label: "Admin", }, { type: FieldType.multiSelect, name: "roles", label: "Accessed by", + labelPlural: "roles", options: roles ?? [], + defaultValue: ["ADMIN"], required: true, freeText: true, - assistiveText: ( + }, + { + type: FieldType.contentParagraph, + name: "_contentParagraph_rules", + label: ( <> - Choose which roles have access to this table. Remember to set the - appropriate Firestore Security Rules for this collection. + To enable access controls for this table, you must set the + corresponding Firestore Security Rules.{" "} - Read about role-based security rules - + Learn how to write rules + - ) as any, + ), + }, + { + type: "suggestedRules", + name: "_suggestedRules", + label: "Suggested Firestore Rules", + watchedField: "collection", }, { type: FieldType.slider, @@ -154,7 +235,7 @@ export const tableSettings = ( Firestore triggers {" "} @@ -164,17 +245,10 @@ export const tableSettings = ( Learn more about this requirement - + ), @@ -183,26 +257,16 @@ export const tableSettings = ( ? { type: FieldType.singleSelect, name: "schemaSource", - label: "Copy column config from existing table", - labelPlural: "Tables", + label: "Copy column config from existing table (optional)", + labelPlural: "tables", options: tables, clearable: true, freeText: false, itemRenderer: (option: { value: string; label: string }) => ( - - {option.label} - theme.typography.fontFamilyMono, - display: "block", - }} - > - {option.value} - - + <> + {option.label}{" "} + {option.value} + ), } : null, diff --git a/src/components/TableSettings/index.tsx b/src/components/TableSettings/index.tsx index 6b89ce06..15978a9e 100644 --- a/src/components/TableSettings/index.tsx +++ b/src/components/TableSettings/index.tsx @@ -1,20 +1,20 @@ -import { useState, useEffect } from "react"; -import _camelCase from "lodash/camelCase"; +import useSWR from "swr"; import _find from "lodash/find"; -import { makeStyles, createStyles } from "@mui/styles"; -import { Button, DialogContentText } from "@mui/material"; - -import Confirmation from "components/Confirmation"; +import { Stack, Button, DialogContentText } from "@mui/material"; import { FormDialog } from "@rowy/form-builder"; import { tableSettings } from "./form"; +import CamelCaseId from "./CamelCaseId"; +import SuggestedRules from "./SuggestedRules"; +import Confirmation from "components/Confirmation"; import { useProjectContext, Table } from "contexts/ProjectContext"; import useRouter from "../../hooks/useRouter"; import { db } from "../../firebase"; import { name } from "@root/package.json"; import { SETTINGS, TABLE_SCHEMAS, TABLE_GROUP_SCHEMAS } from "config/dbPaths"; +import { runRoutes } from "constants/runRoutes"; import { analytics } from "@src/analytics"; export enum TableSettingsDialogModes { @@ -27,68 +27,30 @@ export interface ICreateTableDialogProps { data: Table | null; } -const FORM_EMPTY_STATE = { - name: "", - collection: "", - section: "", - description: "", - roles: ["ADMIN"], -}; - -const useStyles = makeStyles((theme) => - createStyles({ - buttonGrid: { padding: theme.spacing(3, 0) }, - button: { width: 160 }, - - formFooter: { - marginTop: theme.spacing(4), - - "& button": { - paddingLeft: theme.spacing(1.5), - display: "flex", - }, - }, - collectionName: { fontFamily: theme.typography.fontFamilyMono }, - }) -); - export default function TableSettingsDialog({ mode, clearDialog, data, }: ICreateTableDialogProps) { - const classes = useStyles(); - - const { settingsActions, roles, tables } = useProjectContext(); + const { settingsActions, roles, tables, rowyRun } = useProjectContext(); const sectionNames = Array.from( new Set((tables ?? []).map((t) => t.section)) ); const router = useRouter(); + + const { data: collections } = useSWR( + "firebaseCollections", + () => rowyRun?.({ route: runRoutes.listCollections }), + { fallbackData: [], revalidateIfStale: false, dedupingInterval: 60_000 } + ); + const open = mode !== null; - const [formState, setForm] = useState(FORM_EMPTY_STATE); - - const handleChange = (key: string, value: any) => - setForm({ ...formState, [key]: value }); - - useEffect(() => { - if (mode === TableSettingsDialogModes.create) - handleChange("collection", _camelCase(formState.name)); - }, [formState.name]); - - const handleClose = () => { - setForm(FORM_EMPTY_STATE); - clearDialog(); - }; - - useEffect(() => { - if (data) setForm(data); - }, [data]); - if (!open) return null; - const handleSubmit = async (values) => { + const handleSubmit = async (v) => { + const { _suggestedRules, ...values } = v; const data: any = { ...values, }; @@ -97,7 +59,7 @@ export default function TableSettingsDialog({ data.schemaSource = _find(tables, { id: values.schemaSource }); if (mode === TableSettingsDialogModes.update) { - await Promise.all([settingsActions?.updateTable(data), handleClose()]); + await Promise.all([settingsActions?.updateTable(data), clearDialog()]); } else { settingsActions?.createTable(data); @@ -117,13 +79,13 @@ export default function TableSettingsDialog({ type: values.tableType, } ); - handleClose(); + clearDialog(); }; const handleResetStructure = async () => { const schemaDocRef = db.doc(`${TABLE_SCHEMAS}/${data!.id}`); await schemaDocRef.update({ columns: {} }); - handleClose(); + clearDialog(); }; const handleDelete = async () => { @@ -142,79 +104,62 @@ export default function TableSettingsDialog({ .doc(data?.id) .delete(); window.location.reload(); - handleClose(); + clearDialog(); }; return ( ({ label: table.name, value: table.id })) + tables?.map((table) => ({ label: table.name, value: table.id })), + collections )} - values={{ - ...data, + customComponents={{ + camelCaseId: { + component: CamelCaseId, + defaultValue: "", + validation: [["string"]], + }, + suggestedRules: { + component: SuggestedRules, + defaultValue: "", + validation: [["string"]], + }, }} + values={{ ...data }} onSubmit={handleSubmit} SubmitButtonProps={{ children: mode === TableSettingsDialogModes.create ? "Create" : "Update", }} - // customActions={ - // - // - // - // - // - // - // - // - // } formFooter={ mode === TableSettingsDialogModes.update ? ( -
+ - - This will only delete the column structure for this table, - so you can set up the columns again. + + This will only reset the columns of this column so you can + set up the columns again. - You will not lose any data in your Firestore collection - named “ - - {formState.collection} - - ” . + You will not lose any data in your Firestore collection{" "} + {data?.collection}. ), @@ -227,29 +172,23 @@ export default function TableSettingsDialog({ variant="outlined" color="error" onClick={handleResetStructure} - // endIcon={} + style={{ width: 150 }} > - Reset table structure… + Reset columns… -
- - + This will only delete the {name} configuration data. - You will not lose any data in your Firestore collection - named “ - - {formState.collection} - - ” . + You will not lose any data in your Firestore collection{" "} + {data?.collection}. ), @@ -262,12 +201,12 @@ export default function TableSettingsDialog({ variant="outlined" color="error" onClick={handleDelete} - // endIcon={} + style={{ width: 150 }} > Delete table… -
+ ) : null } /> diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index 8e12e889..5716bc67 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -127,7 +127,7 @@ export const ProjectContextProvider: React.FC = ({ children }) => { () => Array.isArray(tables) ? Array.from( - new Set(tables.reduce((a, c) => [...a, ...c.roles], [] as string[])) + new Set(tables.reduce((a, c) => [...a, ...c.roles], ["ADMIN"])) ) : [], [tables] diff --git a/yarn.lock b/yarn.lock index 79cd48a8..84cc3a62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2657,10 +2657,10 @@ estree-walker "^1.0.1" picomatch "^2.2.2" -"@rowy/form-builder@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@rowy/form-builder/-/form-builder-0.2.0.tgz#407c1be0e8fa8e40f57546be4499f7f6b93285de" - integrity sha512-YO/kES8k4xj+1tteF+07gIpb/xrk0ZkyRE+2NxA9loSnkJ3XKDIj0/GadF8CMIdS9cEaRgCHLfswK3pkJjKGCg== +"@rowy/form-builder@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@rowy/form-builder/-/form-builder-0.2.4.tgz#f8f39c85c6142a730ecc84c7d69c871a97c1ff3f" + integrity sha512-Nu5VRcdyPv169xoL7MUZL2Y/2s3nfSJNGmEDcYppiDSZpR/MHN0MFZpv/WwI4Ns9Ogv32hFxkBbI/fF2KGS5Zg== dependencies: "@hookform/resolvers" "^2.6.0" "@mdi/js" "^5.9.55" @@ -2677,10 +2677,10 @@ use-debounce "^3.4.3" yup "^0.32.9" -"@rowy/multiselect@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@rowy/multiselect/-/multiselect-0.2.0.tgz#b915d3cfcd5f7fc2947e97d242510894e454281e" - integrity sha512-PAQ6LCMkEbAS558krXq8UyI8/9rZ/jmNU9cxSyvtnjHF7GVS7ZIRFDU8InV+nzYHsJWF/QYtH7o3X64kRmDrgQ== +"@rowy/multiselect@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@rowy/multiselect/-/multiselect-0.2.1.tgz#86357156b31b9d16e45e08ef2503a98de7b9885f" + integrity sha512-GUDa50Etb81N16K1qq7TVRUc5vOQX5k8OE8SDUnJCrgPOKWkVZaIcIIkEGRpZwEBBvmFocAZGwVO/pEUwNzsAQ== "@sindresorhus/is@^0.14.0": version "0.14.0" @@ -6263,6 +6263,11 @@ depd@~2.0.0: resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== +dequal@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.2.tgz#85ca22025e3a87e65ef75a7a437b35284a7e319d" + integrity sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug== + des.js@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843" @@ -15406,6 +15411,13 @@ swc-loader@^0.1.14: "@swc/core" "^1.2.52" loader-utils "^2.0.0" +swr@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/swr/-/swr-1.0.1.tgz#15f62846b87ee000e52fa07812bb65eb62d79483" + integrity sha512-EPQAxSjoD4IaM49rpRHK0q+/NzcwoT8c0/Ylu/u3/6mFj/CWnQVjNJ0MV2Iuw/U+EJSd2TX5czdAwKPYZIG0YA== + dependencies: + dequal "2.0.2" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"