diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 3503e99b..4e60591f 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -103,6 +103,9 @@ export { FileTableBoxOutline as Project }; import { TableColumn } from "mdi-material-ui"; export { TableColumn }; +import { InformationOutline } from "mdi-material-ui"; +export { InformationOutline as TableInformation }; + export * from "./AddRow"; export * from "./AddRowTop"; export * from "./ChevronDown"; diff --git a/src/atoms/tableScope/ui.ts b/src/atoms/tableScope/ui.ts index fa987118..b5aaf882 100644 --- a/src/atoms/tableScope/ui.ts +++ b/src/atoms/tableScope/ui.ts @@ -121,6 +121,12 @@ export const importAirtableAtom = atom<{ tableId: string; }>({ airtableData: null, apiKey: "", baseId: "", tableId: "" }); +/** Store side drawer open state */ +export const sideDrawerAtom = atomWithHash<"table-information" | null>( + "sideDrawer", + null, + { replaceState: true } +); /** Store side drawer open state */ export const sideDrawerOpenAtom = atom(false); diff --git a/src/components/TableInformationDrawer/Details.tsx b/src/components/TableInformationDrawer/Details.tsx new file mode 100644 index 00000000..a3d4640d --- /dev/null +++ b/src/components/TableInformationDrawer/Details.tsx @@ -0,0 +1,178 @@ +import { useMemo } from "react"; +import { format } from "date-fns"; +import MDEditor from "@uiw/react-md-editor"; + +import { + Box, + Grid, + IconButton, + Stack, + Typography, + useTheme, +} from "@mui/material"; + +import EditIcon from "@mui/icons-material/EditOutlined"; + +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; +import { useAtom, useSetAtom } from "jotai"; +import { + projectScope, + tablesAtom, + tableSettingsDialogAtom, + userRolesAtom, +} from "@src/atoms/projectScope"; +import { find } from "lodash-es"; + +export interface IDetailsProps { + handleOpenTemplate?: any; +} + +export default function Details({ handleOpenTemplate }: IDetailsProps) { + const [userRoles] = useAtom(userRolesAtom, projectScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const [tables] = useAtom(tablesAtom, projectScope); + const openTableSettingsDialog = useSetAtom( + tableSettingsDialogAtom, + projectScope + ); + + const settings = useMemo( + () => find(tables, ["id", tableSettings.id]), + [tables, tableSettings.id] + ); + + const theme = useTheme(); + + if (!settings) { + return null; + } + + const editButton = userRoles.includes("ADMIN") && ( + + openTableSettingsDialog({ + mode: "update", + data: settings, + }) + } + disabled={!openTableSettingsDialog || settings.id.includes("/")} + > + + + ); + + const { description, details, _createdBy } = settings; + + return ( + .MuiGrid-root": { + position: "relative", + }, + }} + > + {/* Description */} + + + + Description + + {editButton} + + + {description ? description : "No description"} + + + + {/* Details */} + + + + Details + + {editButton} + + {!details ? ( + + No details + + ) : ( + + + + )} + + + {/* Table Audits */} + {_createdBy && ( + + + Created by{" "} + + {_createdBy.displayName} + {" "} + at{" "} + + {format(_createdBy.timestamp.toDate(), "LLL d, yyyy · p")} + + + + )} + + {/* Template Settings */} + {/* {handleOpenTemplate && ( + + + + + } + sx={{ maxWidth: "188px" }} + /> + + )} */} + + ); +} diff --git a/src/components/TableInformationDrawer/SideDrawer.tsx b/src/components/TableInformationDrawer/SideDrawer.tsx new file mode 100644 index 00000000..2221230d --- /dev/null +++ b/src/components/TableInformationDrawer/SideDrawer.tsx @@ -0,0 +1,134 @@ +import { useAtom } from "jotai"; +import { RESET } from "jotai/utils"; +import { ErrorBoundary } from "react-error-boundary"; +import clsx from "clsx"; + +import { + Box, + Drawer, + drawerClasses, + IconButton, + Stack, + styled, + Typography, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; + +import { sideDrawerAtom, tableScope } from "@src/atoms/tableScope"; + +import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar"; +import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar/TableToolbar"; +import ErrorFallback from "@src/components/ErrorFallback"; +import Details from "./Details"; + +export const DRAWER_WIDTH = 450; + +export const StyledDrawer = styled(Drawer)(({ theme }) => ({ + [`.${drawerClasses.root}`]: { + width: DRAWER_WIDTH, + flexShrink: 0, + whiteSpace: "nowrap", + }, + + [`.${drawerClasses.paper}`]: { + border: "none", + boxShadow: theme.shadows[4].replace(/, 0 (\d+px)/g, ", -$1 0"), + borderTopLeftRadius: `${(theme.shape.borderRadius as number) * 3}px`, + borderBottomLeftRadius: `${(theme.shape.borderRadius as number) * 3}px`, + + width: DRAWER_WIDTH, + maxWidth: `calc(100% - 28px - ${theme.spacing(1)})`, + + boxSizing: "content-box", + + top: TOP_BAR_HEIGHT + TABLE_TOOLBAR_HEIGHT, + height: `calc(100% - ${TOP_BAR_HEIGHT + TABLE_TOOLBAR_HEIGHT}px)`, + ".MuiDialog-paperFullScreen &": { + top: + TOP_BAR_HEIGHT + + TABLE_TOOLBAR_HEIGHT + + Number(theme.spacing(2).replace("px", "")), + height: `calc(100% - ${ + TOP_BAR_HEIGHT + TABLE_TOOLBAR_HEIGHT + }px - ${theme.spacing(2)})`, + }, + + transition: theme.transitions.create("transform", { + easing: theme.transitions.easing.easeInOut, + duration: theme.transitions.duration.standard, + }), + + zIndex: theme.zIndex.drawer - 1, + }, + [`:not(.sidedrawer-open) .${drawerClasses.paper}`]: { + transform: `translateX(calc(100% - env(safe-area-inset-right)))`, + }, + + ".sidedrawer-contents": { + height: "100%", + overflow: "hidden", + marginLeft: theme.spacing(5), + marginRight: `max(env(safe-area-inset-right), ${theme.spacing(1)})`, + marginTop: theme.spacing(2), + paddingBottom: theme.spacing(5), + }, +})); + +export default function SideDrawer() { + const [sideDrawer, setSideDrawer] = useAtom(sideDrawerAtom, tableScope); + + // const DetailsComponent = + // userRoles.includes("ADMIN") && tableSettings.templateSettings + // ? withTemplate(Details) + // : Details; + // const DetailsComponent = Details; + const open = sideDrawer === "table-information"; + + return ( + + + {open && ( + + + + + Information + + + setSideDrawer(RESET)} + aria-label="Close" + > + + + + + + + + )} + + + ); +} diff --git a/src/components/TableInformationDrawer/index.ts b/src/components/TableInformationDrawer/index.ts new file mode 100644 index 00000000..374e8a86 --- /dev/null +++ b/src/components/TableInformationDrawer/index.ts @@ -0,0 +1,2 @@ +export * from "../TableToolbar/TableInformation"; +export { default } from "../TableToolbar/TableInformation"; diff --git a/src/components/TableInformationDrawer/withTemplate.tsx b/src/components/TableInformationDrawer/withTemplate.tsx new file mode 100644 index 00000000..a42bc9c5 --- /dev/null +++ b/src/components/TableInformationDrawer/withTemplate.tsx @@ -0,0 +1,61 @@ +// import { +// projectScope, +// tablesAtom, +// templateSettingsDialogAtom, +// } from "@src/atoms/projectScope"; +// import { TemplateSettings } from "@src/components/Tables/Templates"; +// import { getTemplateById } from "@src/components/Tables/Templates/utills"; +// import { useAtom, useSetAtom } from "jotai"; +// import { useEffect, useState } from "react"; +import Details from "./Details"; + +export default function withTemplate(DetailsComponent: typeof Details) { + // const [tables] = useAtom(tablesAtom, projectScope); + // const setTemplateSettingsDialog = useSetAtom( + // templateSettingsDialogAtom, + // projectScope + // ); + // const [templateData, setTemplateData] = useState( + // null + // ); + // const { templateSettings } = tableSettings; + // const { draftId, templateId } = templateSettings || {}; + // useEffect(() => { + // if (!templateId) { + // throw Error("Template not found"); + // } + // getTemplateById(templateId).then((template) => setTemplateData(template)); + // }, [templateId]); + // const dialogStateInitializer = () => { + // const matchingTables = + // tables.filter((table) => table.templateSettings?.draftId === draftId) || + // []; + // const templateTables = templateData!.tables.map((templateTable) => ({ + // ...templateTable, + // ...matchingTables.find( + // (matchingTable) => + // matchingTable.templateSettings?.tableId === templateTable.id + // ), + // })); + // const steps = templateData?.steps.map((step) => + // step.type === "create_table" ? { ...step, completed: true } : step + // ); + // return { + // ...templateData, + // tables: templateTables, + // steps, + // } as TemplateSettings; + // }; + // const handleOpenTemplate = () => { + // setTemplateSettingsDialog({ + // type: "set_open", + // data: dialogStateInitializer(), + // }); + // }; + // if (!templateData) { + // return null; + // } + return {}} />; +} + +// export const DetailsWithTemplate = withTemplate(Details); diff --git a/src/components/TableSettingsDialog/TableDetails.tsx b/src/components/TableSettingsDialog/TableDetails.tsx new file mode 100644 index 00000000..705233b8 --- /dev/null +++ b/src/components/TableSettingsDialog/TableDetails.tsx @@ -0,0 +1,43 @@ +import { Box, InputLabel, useTheme } from "@mui/material"; +import MDEditor from "@uiw/react-md-editor"; +import { useState } from "react"; + +export default function TableDetails({ ...props }) { + const { + field: { value, onChange }, + } = props; + const theme = useTheme(); + const [focused, setFocused] = useState(false); + return ( + <> + + {props.label ?? ""} + + + setFocused(true), + onBlur: () => setFocused(false), + }} + {...props} + /> + + > + ); +} diff --git a/src/components/TableSettingsDialog/TableSettingsDialog.tsx b/src/components/TableSettingsDialog/TableSettingsDialog.tsx index 79ea3971..d045739c 100644 --- a/src/components/TableSettingsDialog/TableSettingsDialog.tsx +++ b/src/components/TableSettingsDialog/TableSettingsDialog.tsx @@ -38,6 +38,10 @@ import { getTableSchemaPath, getTableBuildFunctionPathname, } from "@src/utils/table"; +import { firebaseStorageAtom } from "@src/sources/ProjectSourceFirebase"; +import { uploadTableThumbnail } from "./utils"; +import TableThumbnail from "./TableThumbnail"; +import TableDetails from "./TableDetails"; const customComponents = { tableName: { @@ -55,6 +59,12 @@ const customComponents = { defaultValue: "", validation: [["string"]], }, + tableThumbnail: { + component: TableThumbnail, + }, + tableDetails: { + component: TableDetails, + }, }; export default function TableSettingsDialog() { @@ -67,6 +77,7 @@ export default function TableSettingsDialog() { const [projectRoles] = useAtom(projectRolesAtom, projectScope); const [tables] = useAtom(tablesAtom, projectScope); const [rowyRun] = useAtom(rowyRunAtom, projectScope); + const [firebaseStorage] = useAtom(firebaseStorageAtom, projectScope); const navigate = useNavigate(); const confirm = useSetAtom(confirmDialogAtom, projectScope); @@ -96,15 +107,26 @@ export default function TableSettingsDialog() { if (!open) return null; - const handleSubmit = async (v: TableSettings & AdditionalTableSettings) => { + const handleSubmit = async ( + v: TableSettings & AdditionalTableSettings & { thumbnailFile: File } + ) => { const { _schemaSource, _initialColumns, _schema, _suggestedRules, + thumbnailFile, ...values } = v; - const data = { ...values }; + + let thumbnailURL = values.thumbnailURL; + if (thumbnailFile) { + thumbnailURL = await uploadTableThumbnail(firebaseStorage)( + values.id, + thumbnailFile + ); + } + const data = { ...values, thumbnailURL }; const hasExtensions = !isEmpty(get(data, "_schema.extensionObjects")); const hasWebhooks = !isEmpty(get(data, "_schema.webhooks")); diff --git a/src/components/TableSettingsDialog/TableThumbnail.tsx b/src/components/TableSettingsDialog/TableThumbnail.tsx new file mode 100644 index 00000000..41022fb6 --- /dev/null +++ b/src/components/TableSettingsDialog/TableThumbnail.tsx @@ -0,0 +1,126 @@ +import { useCallback, useState } from "react"; +import { useDropzone } from "react-dropzone"; +import { IFieldComponentProps } from "@rowy/form-builder"; + +import { Button, Grid, IconButton, InputLabel, useTheme } from "@mui/material"; + +import { Upload as UploadImageIcon } from "@src/assets/icons"; +import { + OpenInFull as ExpandIcon, + CloseFullscreen as CollapseIcon, + AddPhotoAlternateOutlined as NoImageIcon, +} from "@mui/icons-material"; + +import { IMAGE_MIME_TYPES } from "@src/components/fields/Image"; + +export default function TableThumbnail({ ...props }: IFieldComponentProps) { + const { + name, + useFormMethods: { setValue, getValues }, + } = props; + + const theme = useTheme(); + const [localImage, setLocalImage] = useState( + () => getValues().thumbnailURL + ); + + const [expanded, setExpanded] = useState(false); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + const imageFile = acceptedFiles[0]; + + if (imageFile) { + setLocalImage(URL.createObjectURL(imageFile)); + setValue(name, imageFile); + } + }, + [name, setLocalImage, setValue] + ); + + const { getInputProps } = useDropzone({ + onDrop, + multiple: false, + accept: IMAGE_MIME_TYPES, + }); + + return ( + + + {props.label} + + + + + + + + svg": { + display: localImage ? "none" : "block", + }, + "&:hover": { + opacity: 0.75, + }, + "&:hover > svg": { + display: "block", + }, + }} + onClick={() => setExpanded(!expanded)} + > + {!localImage ? ( + + ) : expanded ? ( + + ) : ( + + )} + + + + + ); +} diff --git a/src/components/TableSettingsDialog/form.tsx b/src/components/TableSettingsDialog/form.tsx index 7b62176b..190b02bf 100644 --- a/src/components/TableSettingsDialog/form.tsx +++ b/src/components/TableSettingsDialog/form.tsx @@ -244,7 +244,23 @@ export const tableSettings = ( type: FieldType.paragraph, name: "description", label: "Description (optional)", - minRows: 2, + }, + { + step: "display", + type: "tableDetails", + name: "details", + label: "Details (optional)", + }, + { + step: "display", + type: "tableThumbnail", + name: "thumbnailFile", + label: "Thumbnail Image (optional)", + }, + { + step: "display", + type: FieldType.hidden, + name: "thumbnailURL", }, // Step 3: Access controls diff --git a/src/components/TableSettingsDialog/utils.ts b/src/components/TableSettingsDialog/utils.ts new file mode 100644 index 00000000..b70cfe07 --- /dev/null +++ b/src/components/TableSettingsDialog/utils.ts @@ -0,0 +1,14 @@ +import { + FirebaseStorage, + getDownloadURL, + ref, + uploadBytes, +} from "firebase/storage"; + +export const uploadTableThumbnail = + (storage: FirebaseStorage) => (tableId: string, imageFile: File) => { + const storageRef = ref(storage, `__thumbnails__/${tableId}`); + return uploadBytes(storageRef, imageFile).then(({ ref }) => + getDownloadURL(ref) + ); + }; diff --git a/src/components/TableToolbar/TableInformation.tsx b/src/components/TableToolbar/TableInformation.tsx new file mode 100644 index 00000000..f53688f1 --- /dev/null +++ b/src/components/TableToolbar/TableInformation.tsx @@ -0,0 +1,25 @@ +import { useAtom } from "jotai"; +import { RESET } from "jotai/utils"; + +import { + sideDrawerAtom, + tableScope, + tableSettingsAtom, +} from "@src/atoms/tableScope"; + +import TableToolbarButton from "@src/components/TableToolbar/TableToolbarButton"; +import { TableInformation as TableInformationIcon } from "@src/assets/icons"; + +export default function TableInformation() { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const [sideDrawer, setSideDrawer] = useAtom(sideDrawerAtom, tableScope); + + return ( + } + onClick={() => setSideDrawer(sideDrawer ? RESET : "table-information")} + disabled={!setSideDrawer || tableSettings.id.includes("/")} + /> + ); +} diff --git a/src/components/TableToolbar/TableToolbar.tsx b/src/components/TableToolbar/TableToolbar.tsx index e940e92b..3e288a88 100644 --- a/src/components/TableToolbar/TableToolbar.tsx +++ b/src/components/TableToolbar/TableToolbar.tsx @@ -36,6 +36,9 @@ import { FieldType } from "@src/constants/fields"; const Filters = lazy(() => import("./Filters" /* webpackChunkName: "Filters" */)); // prettier-ignore const ImportData = lazy(() => import("./ImportData/ImportData" /* webpackChunkName: "ImportData" */)); +// prettier-ignore +const TableInformation = lazy(() => import("./TableInformation" /* webpackChunkName: "TableInformation" */)); + // prettier-ignore const ReExecute = lazy(() => import("./ReExecute" /* webpackChunkName: "ReExecute" */)); @@ -147,6 +150,9 @@ export default function TableToolbar() { > )} + }> + + ); diff --git a/src/components/Tables/TableGrid/TableCard.tsx b/src/components/Tables/TableGrid/TableCard.tsx index 21be4ece..b601b90a 100644 --- a/src/components/Tables/TableGrid/TableCard.tsx +++ b/src/components/Tables/TableGrid/TableCard.tsx @@ -7,10 +7,9 @@ import { Typography, CardActions, Button, + Box, } from "@mui/material"; import { Go as GoIcon } from "@src/assets/icons"; - -import RenderedMarkdown from "@src/components/RenderedMarkdown"; import { TableSettings } from "@src/types/table"; export interface ITableCardProps extends TableSettings { @@ -19,6 +18,7 @@ export interface ITableCardProps extends TableSettings { } export default function TableCard({ + thumbnailURL, section, name, description, @@ -37,27 +37,46 @@ export default function TableCard({ - - - - (theme.typography.body2.lineHeight as number) * 2 + "em", - display: "flex", - flexDirection: "column", - gap: 1, - }} - component="div" - > - {description && ( - + + - )} - - + + + )} + {description && ( + + + {description} + + + )} + + + + + + {!disableModals && ( diff --git a/src/pages/TablesPage.tsx b/src/pages/TablesPage.tsx index 060e46e5..757cdf23 100644 --- a/src/pages/TablesPage.tsx +++ b/src/pages/TablesPage.tsx @@ -1,5 +1,6 @@ import { useAtom, useSetAtom } from "jotai"; import { find, groupBy, sortBy } from "lodash-es"; +import { useNavigate } from "react-router-dom"; import { Container, @@ -13,12 +14,14 @@ import { IconButton, Zoom, } from "@mui/material"; + import ViewListIcon from "@mui/icons-material/ViewListOutlined"; import ViewGridIcon from "@mui/icons-material/ViewModuleOutlined"; import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; import FavoriteIcon from "@mui/icons-material/Favorite"; import EditIcon from "@mui/icons-material/EditOutlined"; import AddIcon from "@mui/icons-material/Add"; +import { TableInformation as TableInformationIcon } from "@src/assets/icons"; import FloatingSearch from "@src/components/FloatingSearch"; import SlideTransition from "@src/components/Modal/SlideTransition"; @@ -54,7 +57,7 @@ export default function TablesPage() { tableSettingsDialogAtom, projectScope ); - + const navigate = useNavigate(); useScrollToHash(); const [results, query, handleQuery] = useBasicSearch( @@ -159,6 +162,16 @@ export default function TablesPage() { sx={view === "list" ? { p: 1.5 } : undefined} color="secondary" /> + { + navigate(`${getLink(table)}#sideDrawer="table-information"`); + }} + style={{ marginLeft: 0 }} + > + + > ); diff --git a/src/sources/ProjectSourceFirebase/useTableFunctions.ts b/src/sources/ProjectSourceFirebase/useTableFunctions.ts index 379f55d2..95bbdcb5 100644 --- a/src/sources/ProjectSourceFirebase/useTableFunctions.ts +++ b/src/sources/ProjectSourceFirebase/useTableFunctions.ts @@ -13,6 +13,7 @@ import { getTableSchemaAtom, AdditionalTableSettings, MinimumTableSettings, + currentUserAtom, } from "@src/atoms/projectScope"; import { firebaseDbAtom } from "./init"; @@ -21,12 +22,14 @@ import { TABLE_SCHEMAS, TABLE_GROUP_SCHEMAS, } from "@src/config/dbPaths"; +import { rowyUser } from "@src/utils/table"; import { TableSettings, TableSchema } from "@src/types/table"; import { FieldType } from "@src/constants/fields"; import { getFieldProp } from "@src/components/fields"; export function useTableFunctions() { const [firebaseDb] = useAtom(firebaseDbAtom, projectScope); + const [currentUser] = useAtom(currentUserAtom, projectScope); // Create a function to get the latest tables from project settings, // so we don’t create new functions when tables change @@ -93,10 +96,11 @@ export function useTableFunctions() { }; } + const _createdBy = currentUser && rowyUser(currentUser); // Appends table to settings doc const promiseUpdateSettings = setDoc( doc(firebaseDb, SETTINGS), - { tables: [...tables, settings] }, + { tables: [...tables, { ...settings, _createdBy }] }, { merge: true } ); @@ -120,7 +124,7 @@ export function useTableFunctions() { await Promise.all([promiseUpdateSettings, promiseAddSchema]); } ); - }, [firebaseDb, readTables, setCreateTable]); + }, [currentUser, firebaseDb, readTables, setCreateTable]); // Set the createTable function const setUpdateTable = useSetAtom(updateTableAtom, projectScope); diff --git a/src/types/table.d.ts b/src/types/table.d.ts index c82ccd54..376cd3ea 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -70,6 +70,18 @@ export type TableSettings = { section: string; description?: string; + details?: string; + thumbnailURL?: string; + + _createdBy?: { + displayName?: string; + email?: string; + emailVerified: boolean; + isAnonymous: boolean; + photoURL?: string; + uid: string; + timestamp: firebase.firestore.Timestamp; + }; tableType: "primaryCollection" | "collectionGroup";