From 6b162beb4e09e21e250cd9b998415e983ed2a07c Mon Sep 17 00:00:00 2001 From: Sidney Alcantara Date: Tue, 24 May 2022 16:17:09 +1000 Subject: [PATCH] add most field types --- package.json | 5 + src/Providers.tsx | 38 ++- src/atoms/globalScope/project.ts | 2 +- src/atoms/tableScope/rowActions.ts | 5 + src/components/ErrorFallback.tsx | 4 + src/components/FormattedChip.tsx | 26 ++ src/components/RenderedHtml.tsx | 98 ++++++ src/components/RichTextEditor.tsx | 160 ++++++++++ .../Settings/UserManagement/InviteUser.tsx | 4 +- .../Settings/UserManagement/UserItem.tsx | 4 +- src/components/SideDrawer/Form/utils.ts | 56 ++++ .../Table/{ => ColumnHeader}/ColumnHeader.tsx | 181 +++-------- .../Table/ColumnHeader/ColumnHeaderSort.tsx | 90 ++++++ src/components/Table/ColumnHeader/index.ts | 2 + src/components/Table/EmptyTable.tsx | 2 +- .../{Skeleton => }/HeaderRowSkeleton.tsx | 0 src/components/Table/Table.tsx | 4 +- src/components/Table/editors/TextEditor.tsx | 7 +- .../Table/formatters/FinalColumn.tsx | 3 +- .../TableHeader/TableHeaderButton.tsx | 30 -- src/components/TableHeader/index.ts | 2 - .../TableSettingsDialog.tsx | 6 +- .../{TableHeader => TableToolbar}/AddRow.tsx | 0 .../HiddenFields.tsx | 0 .../ImportCsv.tsx | 4 +- .../LoadedRowsStatus.tsx | 0 .../ReExecute.tsx | 6 +- .../RowHeight.tsx | 4 +- .../TableSettings.tsx | 4 +- .../TableToolbar.tsx} | 2 +- .../TableToolbar/TableToolbarButton.tsx | 32 ++ .../TableToolbarSkeleton.tsx} | 4 +- src/components/TableToolbar/index.ts | 2 + src/components/Thumbnail.tsx | 124 ++++++++ .../fields/Checkbox/SideDrawerField.tsx | 58 ++++ src/components/fields/Checkbox/TableCell.tsx | 67 ++++ src/components/fields/Checkbox/index.tsx | 46 +++ src/components/fields/Color/InlineCell.tsx | 41 +++ src/components/fields/Color/PopoverCell.tsx | 26 ++ .../fields/Color/SideDrawerField.tsx | 86 +++++ src/components/fields/Color/index.tsx | 34 ++ .../fields/CreatedAt/SideDrawerField.tsx | 31 ++ src/components/fields/CreatedAt/TableCell.tsx | 21 ++ src/components/fields/CreatedAt/index.tsx | 36 +++ src/components/fields/CreatedBy/Settings.tsx | 46 +++ .../fields/CreatedBy/SideDrawerField.tsx | 57 ++++ src/components/fields/CreatedBy/TableCell.tsx | 39 +++ src/components/fields/CreatedBy/index.tsx | 37 +++ src/components/fields/Date/BasicCell.tsx | 25 ++ src/components/fields/Date/Filter.tsx | 35 +++ src/components/fields/Date/Settings.tsx | 40 +++ .../fields/Date/SideDrawerField.tsx | 68 ++++ src/components/fields/Date/TableCell.tsx | 116 +++++++ src/components/fields/Date/index.tsx | 47 +++ src/components/fields/Date/utils.ts | 11 + src/components/fields/DateTime/BasicCell.tsx | 25 ++ src/components/fields/DateTime/Filter.tsx | 35 +++ src/components/fields/DateTime/Settings.tsx | 46 +++ .../fields/DateTime/SideDrawerField.tsx | 72 +++++ src/components/fields/DateTime/TableCell.tsx | 121 +++++++ src/components/fields/DateTime/index.tsx | 48 +++ .../fields/Duration/SideDrawerField.tsx | 31 ++ src/components/fields/Duration/TableCell.tsx | 16 + src/components/fields/Duration/index.tsx | 31 ++ src/components/fields/Duration/utils.ts | 12 + .../fields/Email/SideDrawerField.tsx | 37 +++ src/components/fields/Email/index.tsx | 33 ++ .../fields/File/SideDrawerField.tsx | 187 +++++++++++ src/components/fields/File/TableCell.tsx | 166 ++++++++++ src/components/fields/File/index.tsx | 32 ++ src/components/fields/Id/SideDrawerField.tsx | 12 + src/components/fields/Id/TableCell.tsx | 19 ++ src/components/fields/Id/index.tsx | 28 ++ .../fields/Image/SideDrawerField.tsx | 286 +++++++++++++++++ src/components/fields/Image/TableCell.tsx | 297 ++++++++++++++++++ src/components/fields/Image/index.tsx | 40 +++ src/components/fields/LongText/BasicCell.tsx | 22 ++ .../fields/LongText/SideDrawerField.tsx | 36 +++ src/components/fields/LongText/index.tsx | 35 +++ .../MultiSelect/ConvertStringToArray.tsx | 29 ++ src/components/fields/MultiSelect/Filter.ts | 20 ++ .../fields/MultiSelect/InlineCell.tsx | 61 ++++ .../fields/MultiSelect/PopoverCell.tsx | 41 +++ .../fields/MultiSelect/SideDrawerField.tsx | 69 ++++ src/components/fields/MultiSelect/index.tsx | 55 ++++ src/components/fields/MultiSelect/utils.ts | 4 + src/components/fields/Number/BasicCell.tsx | 5 + src/components/fields/Number/Filter.tsx | 28 ++ .../fields/Number/SideDrawerField.tsx | 32 ++ src/components/fields/Number/index.tsx | 40 +++ .../fields/Percentage/BasicCell.tsx | 41 +++ .../fields/Percentage/SideDrawerField.tsx | 54 ++++ src/components/fields/Percentage/index.tsx | 42 +++ .../fields/Phone/SideDrawerField.tsx | 38 +++ src/components/fields/Phone/index.tsx | 33 ++ src/components/fields/Rating/Settings.tsx | 41 +++ .../fields/Rating/SideDrawerField.tsx | 50 +++ src/components/fields/Rating/TableCell.tsx | 39 +++ src/components/fields/Rating/index.tsx | 40 +++ .../fields/RichText/SideDrawerField.tsx | 24 ++ src/components/fields/RichText/TableCell.tsx | 98 ++++++ src/components/fields/RichText/index.tsx | 34 ++ src/components/fields/ShortText/Filter.tsx | 12 + src/components/fields/ShortText/Settings.tsx | 31 ++ .../fields/ShortText/SideDrawerField.tsx | 34 ++ src/components/fields/ShortText/index.tsx | 40 +++ .../fields/SingleSelect/InlineCell.tsx | 51 +++ .../fields/SingleSelect/PopoverCell.tsx | 41 +++ .../fields/SingleSelect/Settings.tsx | 122 +++++++ .../fields/SingleSelect/SideDrawerField.tsx | 38 +++ src/components/fields/SingleSelect/index.tsx | 45 +++ src/components/fields/SingleSelect/utils.ts | 5 + src/components/fields/Slider/Settings.tsx | 52 +++ .../fields/Slider/SideDrawerField.tsx | 80 +++++ src/components/fields/Slider/TableCell.tsx | 58 ++++ src/components/fields/Slider/index.tsx | 39 +++ .../fields/UpdatedAt/SideDrawerField.tsx | 31 ++ src/components/fields/UpdatedAt/TableCell.tsx | 21 ++ src/components/fields/UpdatedAt/index.tsx | 37 +++ .../fields/UpdatedBy/SideDrawerField.tsx | 63 ++++ src/components/fields/UpdatedBy/TableCell.tsx | 48 +++ src/components/fields/UpdatedBy/index.tsx | 38 +++ src/components/fields/Url/SideDrawerField.tsx | 53 ++++ src/components/fields/Url/TableCell.tsx | 33 ++ src/components/fields/Url/index.tsx | 33 ++ .../fields/User/SideDrawerField.tsx | 57 ++++ src/components/fields/User/TableCell.tsx | 30 ++ src/components/fields/User/index.tsx | 35 +++ .../BasicCellContextMenuActions.tsx | 106 +++++++ .../fields/_BasicCell/BasicCellName.tsx | 5 + .../fields/_BasicCell/BasicCellNull.tsx | 3 + .../fields/_BasicCell/BasicCellValue.tsx | 6 + .../fields/_withTableCell/withBasicCell.tsx | 41 +++ .../fields/_withTableCell/withHeavyCell.tsx | 105 +++++++ .../fields/_withTableCell/withPopoverCell.tsx | 184 +++++++++++ src/components/fields/index.tsx | 134 ++++---- src/components/fields/types.ts | 29 +- src/hooks/useCombinedRefs.ts | 43 --- src/hooks/useFirebaseStorageUploader.tsx | 183 +++++++++++ src/hooks/useFirestoreDocWithAtom.ts | 2 +- src/pages/Table.tsx | 14 +- src/pages/TableTest.tsx | 6 +- src/sources/ProjectSourceFirebase/init.ts | 14 + src/theme/components.tsx | 12 + src/types/table.d.ts | 2 + src/utils/color.ts | 14 + yarn.lock | 85 ++++- 147 files changed, 6241 insertions(+), 337 deletions(-) create mode 100644 src/components/FormattedChip.tsx create mode 100644 src/components/RenderedHtml.tsx create mode 100644 src/components/RichTextEditor.tsx create mode 100644 src/components/SideDrawer/Form/utils.ts rename src/components/Table/{ => ColumnHeader}/ColumnHeader.tsx (52%) create mode 100644 src/components/Table/ColumnHeader/ColumnHeaderSort.tsx create mode 100644 src/components/Table/ColumnHeader/index.ts rename src/components/Table/{Skeleton => }/HeaderRowSkeleton.tsx (100%) delete mode 100644 src/components/TableHeader/TableHeaderButton.tsx delete mode 100644 src/components/TableHeader/index.ts rename src/components/{TableHeader => TableToolbar}/AddRow.tsx (100%) rename src/components/{TableHeader => TableToolbar}/HiddenFields.tsx (100%) rename src/components/{TableHeader => TableToolbar}/ImportCsv.tsx (99%) rename src/components/{TableHeader => TableToolbar}/LoadedRowsStatus.tsx (100%) rename src/components/{TableHeader => TableToolbar}/ReExecute.tsx (96%) rename src/components/{TableHeader => TableToolbar}/RowHeight.tsx (96%) rename src/components/{TableHeader => TableToolbar}/TableSettings.tsx (89%) rename src/components/{TableHeader/TableHeader.tsx => TableToolbar/TableToolbar.tsx} (98%) create mode 100644 src/components/TableToolbar/TableToolbarButton.tsx rename src/components/{Table/Skeleton/TableHeaderSkeleton.tsx => TableToolbar/TableToolbarSkeleton.tsx} (92%) create mode 100644 src/components/TableToolbar/index.ts create mode 100644 src/components/Thumbnail.tsx create mode 100644 src/components/fields/Checkbox/SideDrawerField.tsx create mode 100644 src/components/fields/Checkbox/TableCell.tsx create mode 100644 src/components/fields/Checkbox/index.tsx create mode 100644 src/components/fields/Color/InlineCell.tsx create mode 100644 src/components/fields/Color/PopoverCell.tsx create mode 100644 src/components/fields/Color/SideDrawerField.tsx create mode 100644 src/components/fields/Color/index.tsx create mode 100644 src/components/fields/CreatedAt/SideDrawerField.tsx create mode 100644 src/components/fields/CreatedAt/TableCell.tsx create mode 100644 src/components/fields/CreatedAt/index.tsx create mode 100644 src/components/fields/CreatedBy/Settings.tsx create mode 100644 src/components/fields/CreatedBy/SideDrawerField.tsx create mode 100644 src/components/fields/CreatedBy/TableCell.tsx create mode 100644 src/components/fields/CreatedBy/index.tsx create mode 100644 src/components/fields/Date/BasicCell.tsx create mode 100644 src/components/fields/Date/Filter.tsx create mode 100644 src/components/fields/Date/Settings.tsx create mode 100644 src/components/fields/Date/SideDrawerField.tsx create mode 100644 src/components/fields/Date/TableCell.tsx create mode 100644 src/components/fields/Date/index.tsx create mode 100644 src/components/fields/Date/utils.ts create mode 100644 src/components/fields/DateTime/BasicCell.tsx create mode 100644 src/components/fields/DateTime/Filter.tsx create mode 100644 src/components/fields/DateTime/Settings.tsx create mode 100644 src/components/fields/DateTime/SideDrawerField.tsx create mode 100644 src/components/fields/DateTime/TableCell.tsx create mode 100644 src/components/fields/DateTime/index.tsx create mode 100644 src/components/fields/Duration/SideDrawerField.tsx create mode 100644 src/components/fields/Duration/TableCell.tsx create mode 100644 src/components/fields/Duration/index.tsx create mode 100644 src/components/fields/Duration/utils.ts create mode 100644 src/components/fields/Email/SideDrawerField.tsx create mode 100644 src/components/fields/Email/index.tsx create mode 100644 src/components/fields/File/SideDrawerField.tsx create mode 100644 src/components/fields/File/TableCell.tsx create mode 100644 src/components/fields/File/index.tsx create mode 100644 src/components/fields/Id/SideDrawerField.tsx create mode 100644 src/components/fields/Id/TableCell.tsx create mode 100644 src/components/fields/Id/index.tsx create mode 100644 src/components/fields/Image/SideDrawerField.tsx create mode 100644 src/components/fields/Image/TableCell.tsx create mode 100644 src/components/fields/Image/index.tsx create mode 100644 src/components/fields/LongText/BasicCell.tsx create mode 100644 src/components/fields/LongText/SideDrawerField.tsx create mode 100644 src/components/fields/LongText/index.tsx create mode 100644 src/components/fields/MultiSelect/ConvertStringToArray.tsx create mode 100644 src/components/fields/MultiSelect/Filter.ts create mode 100644 src/components/fields/MultiSelect/InlineCell.tsx create mode 100644 src/components/fields/MultiSelect/PopoverCell.tsx create mode 100644 src/components/fields/MultiSelect/SideDrawerField.tsx create mode 100644 src/components/fields/MultiSelect/index.tsx create mode 100644 src/components/fields/MultiSelect/utils.ts create mode 100644 src/components/fields/Number/BasicCell.tsx create mode 100644 src/components/fields/Number/Filter.tsx create mode 100644 src/components/fields/Number/SideDrawerField.tsx create mode 100644 src/components/fields/Number/index.tsx create mode 100644 src/components/fields/Percentage/BasicCell.tsx create mode 100644 src/components/fields/Percentage/SideDrawerField.tsx create mode 100644 src/components/fields/Percentage/index.tsx create mode 100644 src/components/fields/Phone/SideDrawerField.tsx create mode 100644 src/components/fields/Phone/index.tsx create mode 100644 src/components/fields/Rating/Settings.tsx create mode 100644 src/components/fields/Rating/SideDrawerField.tsx create mode 100644 src/components/fields/Rating/TableCell.tsx create mode 100644 src/components/fields/Rating/index.tsx create mode 100644 src/components/fields/RichText/SideDrawerField.tsx create mode 100644 src/components/fields/RichText/TableCell.tsx create mode 100644 src/components/fields/RichText/index.tsx create mode 100644 src/components/fields/ShortText/Filter.tsx create mode 100644 src/components/fields/ShortText/Settings.tsx create mode 100644 src/components/fields/ShortText/SideDrawerField.tsx create mode 100644 src/components/fields/ShortText/index.tsx create mode 100644 src/components/fields/SingleSelect/InlineCell.tsx create mode 100644 src/components/fields/SingleSelect/PopoverCell.tsx create mode 100644 src/components/fields/SingleSelect/Settings.tsx create mode 100644 src/components/fields/SingleSelect/SideDrawerField.tsx create mode 100644 src/components/fields/SingleSelect/index.tsx create mode 100644 src/components/fields/SingleSelect/utils.ts create mode 100644 src/components/fields/Slider/Settings.tsx create mode 100644 src/components/fields/Slider/SideDrawerField.tsx create mode 100644 src/components/fields/Slider/TableCell.tsx create mode 100644 src/components/fields/Slider/index.tsx create mode 100644 src/components/fields/UpdatedAt/SideDrawerField.tsx create mode 100644 src/components/fields/UpdatedAt/TableCell.tsx create mode 100644 src/components/fields/UpdatedAt/index.tsx create mode 100644 src/components/fields/UpdatedBy/SideDrawerField.tsx create mode 100644 src/components/fields/UpdatedBy/TableCell.tsx create mode 100644 src/components/fields/UpdatedBy/index.tsx create mode 100644 src/components/fields/Url/SideDrawerField.tsx create mode 100644 src/components/fields/Url/TableCell.tsx create mode 100644 src/components/fields/Url/index.tsx create mode 100644 src/components/fields/User/SideDrawerField.tsx create mode 100644 src/components/fields/User/TableCell.tsx create mode 100644 src/components/fields/User/index.tsx create mode 100644 src/components/fields/_BasicCell/BasicCellContextMenuActions.tsx create mode 100644 src/components/fields/_BasicCell/BasicCellName.tsx create mode 100644 src/components/fields/_BasicCell/BasicCellNull.tsx create mode 100644 src/components/fields/_BasicCell/BasicCellValue.tsx create mode 100644 src/components/fields/_withTableCell/withBasicCell.tsx create mode 100644 src/components/fields/_withTableCell/withHeavyCell.tsx create mode 100644 src/components/fields/_withTableCell/withPopoverCell.tsx delete mode 100644 src/hooks/useCombinedRefs.ts create mode 100644 src/hooks/useFirebaseStorageUploader.tsx create mode 100644 src/utils/color.ts diff --git a/package.json b/package.json index 820fa843..7c58665a 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,10 @@ "@mui/lab": "^5.0.0-alpha.76", "@mui/material": "^5.6.0", "@mui/styles": "^5.6.2", + "@mui/x-date-pickers": "^5.0.0-alpha.4", "@rowy/form-builder": "^0.6.1", "@rowy/multiselect": "^0.3.0", + "@tinymce/tinymce-react": "^3", "buffer": "^6.0.3", "compare-versions": "^4.1.3", "csv-parse": "^5.0.4", @@ -44,6 +46,7 @@ "react-error-boundary": "^3.1.4", "react-helmet-async": "^1.3.0", "react-hook-form": "^7.30.0", + "react-image": "^4", "react-markdown": "^8.0.3", "react-router-dom": "^6.3.0", "react-router-hash-link": "^2.4.3", @@ -51,8 +54,10 @@ "remark-gfm": "^3.0.1", "stream-browserify": "^3.0.0", "swr": "^1.3.0", + "tinymce": "^5", "tss-react": "^3.6.2", "typescript": "^4.6.3", + "use-algolia": "^1.5.3", "use-debounce": "^8.0.0", "web-vitals": "^2.1.4" }, diff --git a/src/Providers.tsx b/src/Providers.tsx index 03d64715..c30e05f4 100644 --- a/src/Providers.tsx +++ b/src/Providers.tsx @@ -1,10 +1,12 @@ import { ErrorBoundary } from "react-error-boundary"; import ErrorFallback from "@src/components/ErrorFallback"; -import SwrProvider from "@src/contexts/SwrContext"; +// import SwrProvider from "@src/contexts/SwrContext"; import { BrowserRouter } from "react-router-dom"; import { HelmetProvider } from "react-helmet-async"; import { Provider, Atom } from "jotai"; import { globalScope } from "@src/atoms/globalScope"; +import LocalizationProvider from "@mui/lab/LocalizationProvider"; +import AdapterDateFns from "@mui/lab/AdapterDateFns"; import createCache from "@emotion/cache"; import { CacheProvider } from "@emotion/react"; import RowyThemeProvider from "@src/theme/RowyThemeProvider"; @@ -29,20 +31,26 @@ export default function Providers({ - - - - - - - }> - {children} - - - - - - + + + + + + + + }> + {children} + + + + + + + diff --git a/src/atoms/globalScope/project.ts b/src/atoms/globalScope/project.ts index 6505906e..de98673d 100644 --- a/src/atoms/globalScope/project.ts +++ b/src/atoms/globalScope/project.ts @@ -136,7 +136,7 @@ export const getTableSchemaAtom = atom< >(undefined); /** Roles used in the project based on table settings */ -export const rolesAtom = atom((get) => +export const projectRolesAtom = atom((get) => Array.from( new Set( get(tablesAtom).reduce( diff --git a/src/atoms/tableScope/rowActions.ts b/src/atoms/tableScope/rowActions.ts index 18e19e1b..3cb88605 100644 --- a/src/atoms/tableScope/rowActions.ts +++ b/src/atoms/tableScope/rowActions.ts @@ -296,6 +296,11 @@ export const updateFieldAtom = atom( } // Otherwise, update single field in database else { + await updateRowDb( + row._rowy_ref.path, + update, + deleteField ? [fieldName] : [] + ); } if (auditChange) diff --git a/src/components/ErrorFallback.tsx b/src/components/ErrorFallback.tsx index 587f198a..11d59914 100644 --- a/src/components/ErrorFallback.tsx +++ b/src/components/ErrorFallback.tsx @@ -96,3 +96,7 @@ export default function ErrorFallback({ return ; } + +export function InlineErrorFallback(props: IErrorFallbackProps) { + return ; +} diff --git a/src/components/FormattedChip.tsx b/src/components/FormattedChip.tsx new file mode 100644 index 00000000..6e145d50 --- /dev/null +++ b/src/components/FormattedChip.tsx @@ -0,0 +1,26 @@ +import { Chip, ChipProps } from "@mui/material"; + +export const VARIANTS = ["yes", "no", "maybe"] as const; +const paletteColor = { + yes: "success", + maybe: "warning", + no: "error", +} as const; + +// TODO: Create a more generalised solution for this +export default function FormattedChip(props: ChipProps) { + const label = + typeof props.label === "string" ? props.label.toLowerCase() : ""; + + if (VARIANTS.includes(label as any)) { + return ( + + ); + } + + return ; +} diff --git a/src/components/RenderedHtml.tsx b/src/components/RenderedHtml.tsx new file mode 100644 index 00000000..70d364be --- /dev/null +++ b/src/components/RenderedHtml.tsx @@ -0,0 +1,98 @@ +import DOMPurify from "dompurify"; +import { styled } from "@mui/material"; + +const StyledHtml = styled("div")(({ theme }) => ({ + maxWidth: "33em", + ...theme.typography.body2, + + "& * + *": { + marginTop: "1em !important", + }, + + "& h1, & h2, & h3, & h4, & h5, & h6": { + fontFamily: theme.typography.fontFamily, + margin: 0, + lineHeight: 1.2, + fontWeight: "bold", + }, + "& p": { + margin: 0, + marginTop: "inherit", + }, + + "& a": { + color: theme.palette.primary.main, + textDecoration: "underline", + }, + + "& ul, & ol": { + margin: 0, + paddingLeft: "1.5em", + }, + "& li + li": { + marginTop: "0.5em", + }, + + "& table": { + borderCollapse: "collapse", + }, + + "& table th, & table td": { + border: `1px solid ${theme.palette.divider}`, + padding: "0.4rem", + }, + "& figure": { + display: "table", + margin: "1rem auto", + }, + "& figure figcaption": { + color: "#999", + display: "block", + marginTop: "0.25rem", + textAlign: "center", + }, + "& hr": { + borderColor: `1px solid ${theme.palette.divider}`, + borderWidth: "1px 0 0 0", + }, + "& code": { + backgroundColor: "#e8e8e8", + borderRadius: theme.shape.borderRadius, + padding: "0.1rem 0.2rem", + fontFamily: theme.typography.fontFamilyMono, + }, + "& pre": { + fontFamily: theme.typography.fontFamilyMono, + }, + '& .mceContent-body:not([dir="rtl"]) blockquote': { + borderLeft: `2px solid ${theme.palette.divider}`, + marginLeft: "1.5rem", + paddingLeft: "1rem", + }, + '& .mceContent-body[dir="rtl"] blockquote': { + borderRight: `2px solid ${theme.palette.divider}`, + marginRight: "1.5rem", + paddingRight: "1rem", + }, +})); + +export interface IRenderedHtmlProps + extends React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > { + html: string; +} + +export default function RenderedHtml({ + html, + className, + ...props +}: IRenderedHtmlProps) { + return ( + + ); +} diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx new file mode 100644 index 00000000..15fc191b --- /dev/null +++ b/src/components/RichTextEditor.tsx @@ -0,0 +1,160 @@ +import { useState } from "react"; + +import { styled, useTheme } from "@mui/material"; +import { Editor } from "@tinymce/tinymce-react"; + +// TinyMCE so the global var exists +import "tinymce/tinymce.min.js"; +// Theme +import "tinymce/themes/silver"; +// Toolbar icons +import "tinymce/icons/default"; +// Editor styles +import "tinymce/skins/ui/oxide/skin.min.css"; +// Content styles, including inline UI like fake cursors +/* eslint import/no-webpack-loader-syntax: off */ +import contentCss from "!!raw-loader!tinymce/skins/content/default/content.min.css"; +import contentUiCss from "!!raw-loader!tinymce/skins/ui/oxide/content.min.css"; +import contentCssDark from "!!raw-loader!tinymce/skins/content/dark/content.min.css"; +import contentUiCssDark from "!!raw-loader!tinymce/skins/ui/oxide-dark/content.min.css"; + +// Plugins +import "tinymce/plugins/autoresize"; +import "tinymce/plugins/lists"; +import "tinymce/plugins/link"; +import "tinymce/plugins/image"; +import "tinymce/plugins/paste"; +import "tinymce/plugins/help"; +import "tinymce/plugins/code"; + +const Styles = styled("div")<{ focus?: boolean; disabled?: boolean }>( + ({ theme, focus, disabled }) => ({ + "& .tox": { + "&.tox-tinymce": { + borderRadius: theme.shape.borderRadius, + border: "none", + + backgroundColor: theme.palette.action.input, + 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": { + boxShadow: `0 -1px 0 0 ${theme.palette.text.primary} inset, + 0 0 0 1px ${theme.palette.action.inputOutline} inset`, + }, + }, + + "& .tox-toolbar-overlord, & .tox-edit-area__iframe, & .tox-toolbar__primary": + { + background: "transparent", + borderRadius: theme.shape.borderRadius, + }, + "& .tox-edit-area__iframe": { colorScheme: "auto" }, + + "& .tox-toolbar__group": { border: "none !important" }, + + "& .tox-tbtn": { + borderRadius: theme.shape.borderRadius, + color: theme.palette.text.secondary, + cursor: "pointer", + margin: 0, + + transition: theme.transitions.create(["color", "background-color"], { + duration: theme.transitions.duration.shortest, + }), + + "&:hover": { + color: theme.palette.text.primary, + backgroundColor: "transparent", + }, + + "& svg": { fill: "currentColor" }, + }, + + "& .tox-tbtn--enabled, & .tox-tbtn--enabled:hover": { + backgroundColor: theme.palette.action.selected + " !important", + color: theme.palette.text.primary, + }, + + "& .tox.tox-tinymce, & .tox.tox-tinymce:hover": disabled + ? { + backgroundColor: + theme.palette.mode === "dark" + ? "transparent" + : theme.palette.action.disabledBackground, + } + : focus + ? { + boxShadow: `0 -2px 0 0 ${theme.palette.primary.main} inset, + 0 0 0 1px ${theme.palette.action.inputOutline} inset`, + } + : {}, + }, + }) +); + +export interface IRichTextEditorProps { + value?: string; + onChange: (value: string) => void; + disabled?: boolean; + id: string; +} + +export default function RichTextEditor({ + value, + onChange, + disabled, + id, +}: IRichTextEditorProps) { + const theme = useTheme(); + const [focus, setFocus] = useState(false); + + return ( + + setFocus(true)} + onBlur={() => setFocus(false)} + /> + + ); +} diff --git a/src/components/Settings/UserManagement/InviteUser.tsx b/src/components/Settings/UserManagement/InviteUser.tsx index 370eaa37..dc6e1414 100644 --- a/src/components/Settings/UserManagement/InviteUser.tsx +++ b/src/components/Settings/UserManagement/InviteUser.tsx @@ -17,7 +17,7 @@ import Modal from "@src/components/Modal"; import { globalScope, - rolesAtom, + projectRolesAtom, projectSettingsAtom, rowyRunAtom, rowyRunModalAtom, @@ -26,7 +26,7 @@ import { ROUTES } from "@src/constants/routes"; import { runRoutes } from "@src/constants/runRoutes"; export default function InviteUser() { - const [projectRoles] = useAtom(rolesAtom, globalScope); + const [projectRoles] = useAtom(projectRolesAtom, globalScope); const [projectSettings] = useAtom(projectSettingsAtom, globalScope); const [rowyRun] = useAtom(rowyRunAtom, globalScope); const openRowyRunModal = useSetAtom(rowyRunModalAtom, globalScope); diff --git a/src/components/Settings/UserManagement/UserItem.tsx b/src/components/Settings/UserManagement/UserItem.tsx index 48db5736..2530e2a3 100644 --- a/src/components/Settings/UserManagement/UserItem.tsx +++ b/src/components/Settings/UserManagement/UserItem.tsx @@ -18,7 +18,7 @@ import MultiSelect from "@rowy/multiselect"; import { globalScope, - rolesAtom, + projectRolesAtom, projectSettingsAtom, rowyRunAtom, rowyRunModalAtom, @@ -38,7 +38,7 @@ export default function UserItem({ const confirm = useSetAtom(confirmDialogAtom, globalScope); const openRowyRunModal = useSetAtom(rowyRunModalAtom, globalScope); - const [projectRoles] = useAtom(rolesAtom, globalScope); + const [projectRoles] = useAtom(projectRolesAtom, globalScope); const [projectSettings] = useAtom(projectSettingsAtom, globalScope); const [rowyRun] = useAtom(rowyRunAtom, globalScope); const [updateUser] = useAtom(updateUserAtom, globalScope); diff --git a/src/components/SideDrawer/Form/utils.ts b/src/components/SideDrawer/Form/utils.ts new file mode 100644 index 00000000..251aff73 --- /dev/null +++ b/src/components/SideDrawer/Form/utils.ts @@ -0,0 +1,56 @@ +import { Control } from "react-hook-form"; +import { colord } from "colord"; +import type { SystemStyleObject, Theme } from "@mui/system"; + +import { FieldType } from "@src/constants/fields"; +import { TableRowRef } from "@src/types/table"; + +export interface IFieldProps { + control: Control; + name: string; + docRef: TableRowRef; + editable?: boolean; +} + +export type Values = Record; +export type Field = { + type?: FieldType; + name: string; + label?: string; + [key: string]: any; +}; +export type Fields = (Field | ((values: Values) => Field))[]; + +export const fieldSx: SystemStyleObject = { + borderRadius: 1, + py: 0.5, + px: 1.5, + + backgroundColor: "action.input", + boxShadow: (theme) => + `0 0 0 1px ${ + theme.palette.mode === "dark" + ? colord(theme.palette.divider) + .alpha(colord(theme.palette.divider).alpha() / 2) + .toHslString() + : theme.palette.divider + } inset`, + + "&.Mui-disabled": { + backgroundColor: (theme) => + theme.palette.mode === "dark" + ? "transparent" + : theme.palette.action.disabledBackground, + }, + + width: "100%", + minHeight: 32, + boxSizing: "border-box", + + display: "flex", + textAlign: "left", + alignItems: "center", + + typography: "body2", + color: "text.primary", +}; diff --git a/src/components/Table/ColumnHeader.tsx b/src/components/Table/ColumnHeader/ColumnHeader.tsx similarity index 52% rename from src/components/Table/ColumnHeader.tsx rename to src/components/Table/ColumnHeader/ColumnHeader.tsx index 890c09ea..81bf2a4a 100644 --- a/src/components/Table/ColumnHeader.tsx +++ b/src/components/Table/ColumnHeader/ColumnHeader.tsx @@ -1,63 +1,73 @@ import { useRef } from "react"; import { useAtom, useSetAtom } from "jotai"; -import { HeaderRendererProps } from "react-data-grid"; import { useDrag, useDrop } from "react-dnd"; -import useCombinedRefs from "@src/hooks/useCombinedRefs"; import { + styled, alpha, Tooltip, + TooltipProps, + tooltipClasses, Fade, Grid, IconButton, Typography, } from "@mui/material"; -import SortDescIcon from "@mui/icons-material/ArrowDownward"; import DropdownIcon from "@mui/icons-material/MoreHoriz"; import LockIcon from "@mui/icons-material/LockOutlined"; +import ColumnHeaderSort from "./ColumnHeaderSort"; + import { globalScope, userRolesAtom } from "@src/atoms/globalScope"; -import { - tableScope, - tableOrdersAtom, - updateColumnAtom, -} from "@src/atoms/tableScope"; +import { tableScope, updateColumnAtom } from "@src/atoms/tableScope"; import { FieldType } from "@src/constants/fields"; import { getFieldProp } from "@src/components/fields"; -import { TableOrder } from "@src/types/table"; import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; +import { ColumnConfig } from "@src/types/table"; -export default function DraggableHeaderRenderer({ +const LightTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, + + margin: `-${DEFAULT_ROW_HEIGHT - 2}px 0 0 !important`, + padding: 0, + paddingRight: theme.spacing(1.5), + }, +})); + +export interface IDraggableHeaderRendererProps { + column: ColumnConfig; +} + +export default function DraggableHeaderRenderer({ column, -}: HeaderRendererProps & { - onColumnsReorder: (sourceKey: string, targetKey: string) => void; -}) { +}: IDraggableHeaderRendererProps) { const [userRoles] = useAtom(userRolesAtom, globalScope); - const [tableOrders, setTableOrders] = useAtom(tableOrdersAtom, tableScope); const updateColumn = useSetAtom(updateColumnAtom, tableScope); - const [{ isDragging }, drag] = useDrag({ + const [{ isDragging }, dragRef] = useDrag({ type: "COLUMN_DRAG", item: { key: column.key }, collect: (monitor) => ({ - isDragging: !!monitor.isDragging(), + isDragging: monitor.isDragging(), }), }); - // FIXME: - // const [{ isOver }, drop] = useDrop({ - // accept: "COLUMN_DRAG", - // drop: ({ key }) => { - // tableActions?.column.reorder(key, column.key); - // }, - // collect: (monitor) => ({ - // isOver: !!monitor.isOver(), - // canDrop: !!monitor.canDrop(), - // }), - // }); - const isOver = false; + const [{ isOver }, dropRef] = useDrop({ + accept: "COLUMN_DRAG", + drop: ({ key }: { key: string }) => { + console.log("drop", key, column.index); + updateColumn({ key, config: {}, index: column.index }); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }); - const headerRef = useCombinedRefs(drag, drag /** FIXME: drop */); const buttonRef = useRef(null); const handleOpenMenu = (e: React.MouseEvent) => { @@ -68,26 +78,13 @@ export default function DraggableHeaderRenderer({ // anchorEl: buttonRef.current, // }); }; - const _sortKey = getFieldProp("sortKey", (column as any).type); - const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key; - - const isSorted = tableOrders[0]?.key === sortKey; - const isAsc = isSorted && tableOrders[0]?.direction === "asc"; - const isDesc = isSorted && tableOrders[0]?.direction === "desc"; - - const handleSortClick = () => { - let newOrders: TableOrder[] = []; - - if (!isSorted) newOrders = [{ key: sortKey, direction: "desc" }]; - else if (isDesc) newOrders = [{ key: sortKey, direction: "asc" }]; - else newOrders = []; - - setTableOrders(newOrders); - }; return ( { + dragRef(ref); + dropRef(ref); + }} container alignItems="center" wrap="nowrap" @@ -124,6 +121,7 @@ export default function DraggableHeaderRenderer({ } : {}, ]} + className="column-header" > {(column.width as number) > 140 && ( ({ } enterDelay={1000} placement="bottom-start" + arrow > ({ xs sx={{ flexShrink: 1, overflow: "hidden", my: 0, ml: 0.5, mr: -30 / 8 }} > - ({ enterDelay={1000} placement="bottom-start" disableInteractive - // PopperProps={{ - // modifiers: [ - // { - // name: "flip", - // options: { - // enabled: false, - // }, - // }, - // { - // name: "preventOverflow", - // options: { - // enabled: false, - // boundariesElement: "scrollParent", - // }, - // }, - // { - // name: "hide", - // options: { - // enabled: false, - // }, - // }, - // ], - // }} TransitionComponent={Fade} - sx={{ - "& .MuiTooltip-tooltip": { - background: "background.default", - color: "text.primary", - - margin: `-${DEFAULT_ROW_HEIGHT}px 0 0 !important`, - p: 0, - pr: 1.5, - - "& *": { lineHeight: "40px" }, - }, - }} > ({ > {column.name as string} - + - {(column as any).type !== FieldType.id && ( - - theme.transitions.create("opacity", { - duration: theme.transitions.duration.shortest, - }), - - "$root:hover &": { opacity: 1 }, - }} - > - - - theme.transitions.create(["background-color", "transform"], { - duration: theme.transitions.duration.short, - }), - transform: isAsc ? "rotate(180deg)" : "none", - }} - > - - - - - )} + + + {(userRoles.includes("ADMIN") || (userRoles.includes("OPS") && @@ -292,7 +215,7 @@ export default function DraggableHeaderRenderer({ }), color: "text.disabled", - "$root:hover &": { color: "text.primary" }, + ".column-header:hover &": { color: "text.primary" }, }} > diff --git a/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx b/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx new file mode 100644 index 00000000..150c40a3 --- /dev/null +++ b/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx @@ -0,0 +1,90 @@ +import { useAtom } from "jotai"; + +import { Tooltip, IconButton } from "@mui/material"; +import SortDescIcon from "@mui/icons-material/ArrowDownward"; + +import { tableScope, tableOrdersAtom } from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; +import { getFieldProp } from "@src/components/fields"; + +import { ColumnConfig } from "@src/types/table"; + +const SORT_STATES = ["none", "desc", "asc"] as const; + +export interface IColumnHeaderSortProps { + column: ColumnConfig; +} + +export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { + const [tableOrders, setTableOrders] = useAtom(tableOrdersAtom, tableScope); + + const _sortKey = getFieldProp("sortKey", (column as any).type); + const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key; + + const currentSort: typeof SORT_STATES[number] = + tableOrders[0]?.key !== sortKey + ? "none" + : tableOrders[0]?.direction || "none"; + const nextSort = + SORT_STATES[SORT_STATES.indexOf(currentSort) + 1] ?? SORT_STATES[0]; + + const handleSortClick = () => { + if (nextSort === "none") setTableOrders([]); + else setTableOrders([{ key: sortKey, direction: nextSort }]); + }; + + if (column.type === FieldType.id) return null; + + return ( + + + theme.transitions.create( + ["background-color", "transform", "opacity"], + { + duration: theme.transitions.duration.short, + } + ), + transform: currentSort === "asc" ? "rotate(180deg)" : "none", + + "&:hover": { + transform: + currentSort === "asc" || nextSort === "asc" + ? "rotate(180deg)" + : "none", + }, + + "& .slash": { opacity: currentSort === "none" ? 1 : 0 }, + "&:hover .slash": { opacity: nextSort === "none" ? 1 : 0 }, + }} + > + + + + + + + + ); +} diff --git a/src/components/Table/ColumnHeader/index.ts b/src/components/Table/ColumnHeader/index.ts new file mode 100644 index 00000000..ebaa7222 --- /dev/null +++ b/src/components/Table/ColumnHeader/index.ts @@ -0,0 +1,2 @@ +export * from "./ColumnHeader"; +export { default } from "./ColumnHeader"; diff --git a/src/components/Table/EmptyTable.tsx b/src/components/Table/EmptyTable.tsx index 315f6314..b9893dd5 100644 --- a/src/components/Table/EmptyTable.tsx +++ b/src/components/Table/EmptyTable.tsx @@ -14,7 +14,7 @@ import { APP_BAR_HEIGHT } from "@src/layouts/Navigation"; // FIXME: // import ColumnMenu from "./ColumnMenu"; // import ImportWizard from "@src/components/Wizards/ImportWizard"; -// import ImportCSV from "@src/components/TableHeader/ImportCsv"; +// import ImportCSV from "@src/components/TableToolbar/ImportCsv"; export default function EmptyTable() { const [tableSettings] = useAtom(tableSettingsAtom, tableScope); diff --git a/src/components/Table/Skeleton/HeaderRowSkeleton.tsx b/src/components/Table/HeaderRowSkeleton.tsx similarity index 100% rename from src/components/Table/Skeleton/HeaderRowSkeleton.tsx rename to src/components/Table/HeaderRowSkeleton.tsx diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 90a30380..0800233b 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -13,7 +13,7 @@ import DataGrid, { } from "react-data-grid"; import TableContainer, { OUT_OF_ORDER_MARGIN } from "./TableContainer"; -import TableHeader from "@src/components/TableHeader"; +import TableToolbar from "@src/components/TableToolbar/TableToolbar"; import ColumnHeader from "./ColumnHeader"; // import ColumnMenu from "./ColumnMenu"; // import ContextMenu from "./ContextMenu"; @@ -189,7 +189,7 @@ export default function Table() { */} - + ) { const updateField = useSetAtom(updateFieldAtom, tableScope); @@ -22,9 +23,11 @@ export default function TextEditor({ row, column }: EditorProps) { const inputRef = useRef(null); + // WARNING: THIS DOES NOT WORK IN REACT 18 STRICT MODE useLayoutEffect(() => { + const inputElement = inputRef.current; return () => { - const newValue = inputRef.current?.value; + const newValue = inputElement?.value; let formattedValue: any = newValue; if (newValue !== undefined) { if (type === FieldType.number) { @@ -40,7 +43,7 @@ export default function TextEditor({ row, column }: EditorProps) { }); } }; - }, []); + }, [column.key, row._rowy_ref.path, type, updateField]); let inputType = "text"; switch (type) { diff --git a/src/components/Table/formatters/FinalColumn.tsx b/src/components/Table/formatters/FinalColumn.tsx index 49ee24dd..47809a93 100644 --- a/src/components/Table/formatters/FinalColumn.tsx +++ b/src/components/Table/formatters/FinalColumn.tsx @@ -18,8 +18,9 @@ import { deleteRowAtom, } from "@src/atoms/tableScope"; import useKeyPress from "@src/hooks/useKeyPress"; +import { TableRow } from "@src/types/table"; -export default function FinalColumn({ row }: FormatterProps) { +export default function FinalColumn({ row }: FormatterProps) { const [userRoles] = useAtom(userRolesAtom, globalScope); const [addRowIdType] = useAtom(tableAddRowIdTypeAtom, globalScope); const confirm = useSetAtom(confirmDialogAtom, globalScope); diff --git a/src/components/TableHeader/TableHeaderButton.tsx b/src/components/TableHeader/TableHeaderButton.tsx deleted file mode 100644 index 6733f740..00000000 --- a/src/components/TableHeader/TableHeaderButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { forwardRef } from "react"; -import { Tooltip, Button, ButtonProps } from "@mui/material"; - -export interface ITableHeaderButtonProps extends Partial { - title: string; - icon: React.ReactNode; -} - -export const TableHeaderButton = forwardRef(function TableHeaderButton_( - { title, icon, ...props }: ITableHeaderButtonProps, - ref: React.Ref -) { - return ( - - - - ); -}); - -export default TableHeaderButton; diff --git a/src/components/TableHeader/index.ts b/src/components/TableHeader/index.ts deleted file mode 100644 index edd1e42e..00000000 --- a/src/components/TableHeader/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./TableHeader"; -export { default } from "./TableHeader"; diff --git a/src/components/TableSettingsDialog/TableSettingsDialog.tsx b/src/components/TableSettingsDialog/TableSettingsDialog.tsx index d0f6902b..ee811cf7 100644 --- a/src/components/TableSettingsDialog/TableSettingsDialog.tsx +++ b/src/components/TableSettingsDialog/TableSettingsDialog.tsx @@ -20,7 +20,7 @@ import { globalScope, tableSettingsDialogAtom, tablesAtom, - rolesAtom, + projectRolesAtom, rowyRunAtom, confirmDialogAtom, createTableAtom, @@ -64,7 +64,7 @@ export default function TableSettingsDialog() { ); const clearDialog = () => setTableSettingsDialog({ open: false }); - const [roles] = useAtom(rolesAtom, globalScope); + const [projectRoles] = useAtom(projectRolesAtom, globalScope); const [tables] = useAtom(tablesAtom, globalScope); const [rowyRun] = useAtom(rowyRunAtom, globalScope); @@ -225,7 +225,7 @@ export default function TableSettingsDialog() { const fields = tableSettings( mode, - roles, + projectRoles, sectionNames, sortBy( tables?.map((table) => ({ diff --git a/src/components/TableHeader/AddRow.tsx b/src/components/TableToolbar/AddRow.tsx similarity index 100% rename from src/components/TableHeader/AddRow.tsx rename to src/components/TableToolbar/AddRow.tsx diff --git a/src/components/TableHeader/HiddenFields.tsx b/src/components/TableToolbar/HiddenFields.tsx similarity index 100% rename from src/components/TableHeader/HiddenFields.tsx rename to src/components/TableToolbar/HiddenFields.tsx diff --git a/src/components/TableHeader/ImportCsv.tsx b/src/components/TableToolbar/ImportCsv.tsx similarity index 99% rename from src/components/TableHeader/ImportCsv.tsx rename to src/components/TableToolbar/ImportCsv.tsx index 796a198d..7a8464c7 100644 --- a/src/components/TableHeader/ImportCsv.tsx +++ b/src/components/TableToolbar/ImportCsv.tsx @@ -20,7 +20,7 @@ import TabContext from "@mui/lab/TabContext"; import TabList from "@mui/lab/TabList"; import TabPanel from "@mui/lab/TabPanel"; -import TableHeaderButton from "./TableHeaderButton"; +import TableToolbarButton from "./TableToolbarButton"; import ImportIcon from "@src/assets/icons/Import"; import FileUploadIcon from "@src/assets/icons/Upload"; import CheckIcon from "@mui/icons-material/CheckCircle"; @@ -176,7 +176,7 @@ export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) { {render ? ( render(handleOpen) ) : ( - } diff --git a/src/components/TableHeader/LoadedRowsStatus.tsx b/src/components/TableToolbar/LoadedRowsStatus.tsx similarity index 100% rename from src/components/TableHeader/LoadedRowsStatus.tsx rename to src/components/TableToolbar/LoadedRowsStatus.tsx diff --git a/src/components/TableHeader/ReExecute.tsx b/src/components/TableToolbar/ReExecute.tsx similarity index 96% rename from src/components/TableHeader/ReExecute.tsx rename to src/components/TableToolbar/ReExecute.tsx index 5f51f6de..09e7f00a 100644 --- a/src/components/TableHeader/ReExecute.tsx +++ b/src/components/TableToolbar/ReExecute.tsx @@ -7,7 +7,7 @@ import { writeBatch, } from "firebase/firestore"; -import TableHeaderButton from "./TableHeaderButton"; +import TableToolbarButton from "./TableToolbarButton"; import LoopIcon from "@mui/icons-material/Loop"; import Modal from "@src/components/Modal"; import CircularProgressOptical from "@src/components/CircularProgressOptical"; @@ -37,7 +37,7 @@ export default function ReExecute() { if (!projectSettings.rowyRunUrl) return ( - openRowyRunModal({ feature: "Force refresh" })} icon={} @@ -70,7 +70,7 @@ export default function ReExecute() { return ( <> - setOpen(true)} icon={} diff --git a/src/components/TableHeader/RowHeight.tsx b/src/components/TableToolbar/RowHeight.tsx similarity index 96% rename from src/components/TableHeader/RowHeight.tsx rename to src/components/TableToolbar/RowHeight.tsx index 5d23d6dc..0e50a490 100644 --- a/src/components/TableHeader/RowHeight.tsx +++ b/src/components/TableToolbar/RowHeight.tsx @@ -3,7 +3,7 @@ import { useAtom } from "jotai"; import { useTheme, TextField, ListSubheader, MenuItem } from "@mui/material"; import RowHeightIcon from "@src/assets/icons/RowHeight"; -import TableHeaderButton from "./TableHeaderButton"; +import TableToolbarButton from "./TableToolbarButton"; import { tableScope, @@ -30,7 +30,7 @@ export default function RowHeight() { return ( <> - } diff --git a/src/components/TableHeader/TableSettings.tsx b/src/components/TableToolbar/TableSettings.tsx similarity index 89% rename from src/components/TableHeader/TableSettings.tsx rename to src/components/TableToolbar/TableSettings.tsx index 3d97c828..f10212d4 100644 --- a/src/components/TableHeader/TableSettings.tsx +++ b/src/components/TableToolbar/TableSettings.tsx @@ -1,6 +1,6 @@ import { useAtom, useSetAtom } from "jotai"; -import TableHeaderButton from "./TableHeaderButton"; +import TableToolbarButton from "./TableToolbarButton"; import SettingsIcon from "@mui/icons-material/SettingsOutlined"; import { globalScope, tableSettingsDialogAtom } from "@src/atoms/globalScope"; @@ -14,7 +14,7 @@ export default function TableSettings() { ); return ( - openTableSettingsDialog({ mode: "update", data: tableSettings }) diff --git a/src/components/TableHeader/TableHeader.tsx b/src/components/TableToolbar/TableToolbar.tsx similarity index 98% rename from src/components/TableHeader/TableHeader.tsx rename to src/components/TableToolbar/TableToolbar.tsx index 11b4a612..98bd4c46 100644 --- a/src/components/TableHeader/TableHeader.tsx +++ b/src/components/TableToolbar/TableToolbar.tsx @@ -26,7 +26,7 @@ import { FieldType } from "@src/constants/fields"; export const TABLE_HEADER_HEIGHT = 44; -export default function TableHeader() { +export default function TableToolbar() { const [userRoles] = useAtom(userRolesAtom, globalScope); const [tableSettings] = useAtom(tableSettingsAtom, tableScope); const [tableSchema] = useAtom(tableSchemaAtom, tableScope); diff --git a/src/components/TableToolbar/TableToolbarButton.tsx b/src/components/TableToolbar/TableToolbarButton.tsx new file mode 100644 index 00000000..40f4b065 --- /dev/null +++ b/src/components/TableToolbar/TableToolbarButton.tsx @@ -0,0 +1,32 @@ +import { forwardRef } from "react"; +import { Tooltip, Button, ButtonProps } from "@mui/material"; + +export interface ITableToolbarButtonProps extends Partial { + title: string; + icon: React.ReactNode; +} + +export const TableToolbarButton = forwardRef(function TableToolbarButton_( + { title, icon, ...props }: ITableToolbarButtonProps, + ref: React.Ref +) { + return ( + + + + + + ); +}); + +export default TableToolbarButton; diff --git a/src/components/Table/Skeleton/TableHeaderSkeleton.tsx b/src/components/TableToolbar/TableToolbarSkeleton.tsx similarity index 92% rename from src/components/Table/Skeleton/TableHeaderSkeleton.tsx rename to src/components/TableToolbar/TableToolbarSkeleton.tsx index 34ca7936..600520a6 100644 --- a/src/components/Table/Skeleton/TableHeaderSkeleton.tsx +++ b/src/components/TableToolbar/TableToolbarSkeleton.tsx @@ -2,7 +2,7 @@ import { Fade, Stack, Button, Skeleton, SkeletonProps } from "@mui/material"; import AddRowIcon from "@src/assets/icons/AddRow"; // FIXME: -// import { TABLE_HEADER_HEIGHT } from "@src/components/TableHeader"; +// import { TABLE_HEADER_HEIGHT } from "@src/components/TableToolbar"; const TABLE_HEADER_HEIGHT = 44; const ButtonSkeleton = (props: Partial) => ( @@ -13,7 +13,7 @@ const ButtonSkeleton = (props: Partial) => ( /> ); -export default function TableHeaderSkeleton() { +export default function TableToolbarSkeleton() { return ( , + HTMLImageElement + > { + imageUrl: string; + size?: string; + + objectFit?: string; + shape?: "roundedRectangle" | "square" | "circle"; + border?: boolean; + + sx?: BoxProps["sx"]; +} + +/** + * Display a thumbnail generated by compressedThumbnail cloud function, + * falling back to original image if it doesn’t load. + * + * Uses react-image: https://github.com/mbrevda/react-image + */ +export function Thumbnail({ + imageUrl, + size = "200x200", + + objectFit = "cover", + shape = "roundedRectangle", + border = false, + + ...props +}: IThumbnailProps) { + // Add size suffix just before file name extension (e.g. .jpg) + const thumbnailUrl = imageUrl.replace( + /(\.[\w]+\?.*token=[\w-]+$)/, + `__${size}$1` + ); + + const { src, error } = useImage({ + srcList: [thumbnailUrl, imageUrl], + }); + + if (error) return <>x; + + return ( + + border ? `0 0 0 1px ${theme.palette.divider} inset` : "none", + }, + }, + ...(Array.isArray(props.sx) ? props.sx : [props.sx]), + ]} + /> + ); +} + +/** + * Wrap thumbnail in an ErrorBoundary and Skeleton for loading + */ +export default function ErrorBoundedThumbnail(props: IThumbnailProps) { + return ( + + } + > + + } + > + + + + ); +} diff --git a/src/components/fields/Checkbox/SideDrawerField.tsx b/src/components/fields/Checkbox/SideDrawerField.tsx new file mode 100644 index 00000000..bacb4898 --- /dev/null +++ b/src/components/fields/Checkbox/SideDrawerField.tsx @@ -0,0 +1,58 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { ButtonBase, FormControlLabel, Switch } from "@mui/material"; + +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; + +export default function Checkbox({ + column, + control, + disabled, +}: ISideDrawerFieldProps) { + return ( + { + const handleChange = (event: React.ChangeEvent) => { + onChange(event.target.checked); + }; + + return ( + + + } + label={column.name as string} + labelPlacement="start" + sx={{ + mx: 0, + my: -0.25, + width: "100%", + alignItems: "center", + + "& .MuiFormControlLabel-label": { + font: "inherit", + letterSpacing: "inherit", + flexGrow: 1, + overflowX: "hidden", + mt: 0, + }, + + "& .MuiSwitch-root": { mr: -1.25 }, + }} + /> + + ); + }} + /> + ); +} diff --git a/src/components/fields/Checkbox/TableCell.tsx b/src/components/fields/Checkbox/TableCell.tsx new file mode 100644 index 00000000..54f67f08 --- /dev/null +++ b/src/components/fields/Checkbox/TableCell.tsx @@ -0,0 +1,67 @@ +import { IHeavyCellProps } from "@src/components/fields/types"; +import { useSetAtom } from "jotai"; +import { get } from "lodash-es"; + +import { FormControlLabel, Switch } from "@mui/material"; +import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope"; + +const replacer = (data: any) => (m: string, key: string) => { + const objKey = key.split(":")[0]; + const defaultValue = key.split(":")[1] || ""; + return get(data, objKey, defaultValue); +}; + +export default function Checkbox({ + row, + column, + value, + onSubmit, + disabled, +}: IHeavyCellProps) { + const confirm = useSetAtom(confirmDialogAtom, globalScope); + + const handleChange = () => { + if (column?.config?.confirmation) { + confirm({ + title: column.config.confirmation.title, + body: column.config.confirmation.body.replace( + /\{\{(.*?)\}\}/g, + replacer(row) + ), + handleConfirm: () => onSubmit(!value), + }); + } else { + onSubmit(!value); + } + }; + + return ( + + } + label={column.name as string} + labelPlacement="start" + sx={{ + m: 0, + width: "100%", + alignItems: "center", + + "& .MuiFormControlLabel-label": { + font: "inherit", + letterSpacing: "inherit", + flexGrow: 1, + overflowX: "hidden", + mt: "0 !important", + }, + + "& .MuiSwitch-root": { mr: -0.75 }, + }} + /> + ); +} diff --git a/src/components/fields/Checkbox/index.tsx b/src/components/fields/Checkbox/index.tsx new file mode 100644 index 00000000..848a52c0 --- /dev/null +++ b/src/components/fields/Checkbox/index.tsx @@ -0,0 +1,46 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import CheckboxIcon from "@mui/icons-material/ToggleOnOutlined"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellName"; +import NullEditor from "@src/components/Table/editors/NullEditor"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-Checkbox" */) +); +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-Checkbox" */ + ) +); + +export const config: IFieldConfig = { + type: FieldType.checkbox, + name: "Toggle", + group: "Numeric", + dataType: "boolean", + initialValue: false, + initializable: true, + icon: , + description: "True/false value. Default: false.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: NullEditor as any, + csvImportParser: (value: string) => { + if (["YES", "TRUE", "1"].includes(value.toUpperCase())) return true; + else if (["NO", "FALSE", "0"].includes(value.toUpperCase())) return false; + else return null; + }, + filter: { + operators: [ + { + value: "==", + label: "is", + }, + ], + defaultValue: false, + }, + SideDrawerField, +}; +export default config; diff --git a/src/components/fields/Color/InlineCell.tsx b/src/components/fields/Color/InlineCell.tsx new file mode 100644 index 00000000..af36b90f --- /dev/null +++ b/src/components/fields/Color/InlineCell.tsx @@ -0,0 +1,41 @@ +import { forwardRef } from "react"; +import { IPopoverInlineCellProps } from "@src/components/fields/types"; + +import { ButtonBase, Box } from "@mui/material"; + +export const Color = forwardRef(function Color( + { value, showPopoverCell, disabled }: IPopoverInlineCellProps, + ref: React.Ref +) { + return ( + showPopoverCell(true)} + ref={ref} + disabled={disabled} + className="cell-collapse-padding" + sx={{ + font: "inherit", + letterSpacing: "inherit", + p: "var(--cell-padding)", + justifyContent: "flex-start", + height: "100%", + }} + > + `0 0 0 1px ${theme.palette.divider} inset`, + borderRadius: 0.5, + }} + /> + + {value?.hex} + + ); +}); + +export default Color; diff --git a/src/components/fields/Color/PopoverCell.tsx b/src/components/fields/Color/PopoverCell.tsx new file mode 100644 index 00000000..84832e72 --- /dev/null +++ b/src/components/fields/Color/PopoverCell.tsx @@ -0,0 +1,26 @@ +import { IPopoverCellProps } from "@src/components/fields/types"; +import { ColorPicker, toColor } from "react-color-palette"; +import { useDebouncedCallback } from "use-debounce"; +import "react-color-palette/lib/css/styles.css"; +import { useEffect, useState } from "react"; + +export default function Color({ value, onSubmit }: IPopoverCellProps) { + const [localValue, setLocalValue] = useState(value); + const handleChangeComplete = useDebouncedCallback((color) => { + onSubmit(color); + }, 400); + + useEffect(() => { + handleChangeComplete(localValue); + }, [localValue]); + + return ( + + ); +} diff --git a/src/components/fields/Color/SideDrawerField.tsx b/src/components/fields/Color/SideDrawerField.tsx new file mode 100644 index 00000000..677d52ab --- /dev/null +++ b/src/components/fields/Color/SideDrawerField.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; +import { ColorPicker, toColor } from "react-color-palette"; +import "react-color-palette/lib/css/styles.css"; + +import { ButtonBase, Box, Collapse } from "@mui/material"; + +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; + +export default function Color({ + column, + control, + disabled, +}: ISideDrawerFieldProps) { + const [showPicker, setShowPicker] = useState(false); + const toggleOpen = () => setShowPicker((s) => !s); + + return ( + { + return ( + <> + { + toggleOpen(); + onBlur(); + }} + component={ButtonBase} + focusRipple + disabled={disabled} + sx={[ + fieldSx, + { + justifyContent: "flex-start", + "&&": { pl: 0.75 }, + color: value?.hex ? "textPrimary" : "textSecondary", + }, + ]} + > + + `0 0 0 1px ${theme.palette.divider} inset`, + borderRadius: 0.5, + }} + /> + + {value?.hex ?? "Choose a color…"} + + + `0 0 0 1px ${theme.palette.divider}`, + m: 1 / 8, + }, + "& .rcp-saturation": { + borderRadius: 1, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + }, + }} + > + + + + ); + }} + /> + ); +} diff --git a/src/components/fields/Color/index.tsx b/src/components/fields/Color/index.tsx new file mode 100644 index 00000000..c927e908 --- /dev/null +++ b/src/components/fields/Color/index.tsx @@ -0,0 +1,34 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; + +import ColorIcon from "@mui/icons-material/Colorize"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import InlineCell from "./InlineCell"; +import NullEditor from "@src/components/Table/editors/NullEditor"; + +const PopoverCell = lazy( + () => import("./PopoverCell" /* webpackChunkName: "PopoverCell-Color" */) +); +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Color" */) +); + +export const config: IFieldConfig = { + type: FieldType.color, + name: "Color", + group: "Numeric", + dataType: "Record", + initialValue: {}, + initializable: true, + icon: , + description: + "Color stored as Hex, RGB, and HSV. Edited with a visual picker.", + TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { + anchorOrigin: { horizontal: "left", vertical: "bottom" }, + }), + TableEditor: NullEditor as any, + SideDrawerField, +}; +export default config; diff --git a/src/components/fields/CreatedAt/SideDrawerField.tsx b/src/components/fields/CreatedAt/SideDrawerField.tsx new file mode 100644 index 00000000..3d5bd123 --- /dev/null +++ b/src/components/fields/CreatedAt/SideDrawerField.tsx @@ -0,0 +1,31 @@ +import { useWatch } from "react-hook-form"; +import { useAtom } from "jotai"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { Box } from "@mui/material"; +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; + +import { format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; + +export default function CreatedAt({ control, column }: ISideDrawerFieldProps) { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const value = useWatch({ + control, + name: tableSettings.auditFieldCreatedBy || "_createdBy", + }); + + if (!value || !value.timestamp) return ; + + const dateLabel = format( + value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, + column.config?.format || DATE_TIME_FORMAT + ); + + return ( + + {dateLabel} + + ); +} diff --git a/src/components/fields/CreatedAt/TableCell.tsx b/src/components/fields/CreatedAt/TableCell.tsx new file mode 100644 index 00000000..7fd6e5a8 --- /dev/null +++ b/src/components/fields/CreatedAt/TableCell.tsx @@ -0,0 +1,21 @@ +import { useAtom } from "jotai"; +import { IHeavyCellProps } from "@src/components/fields/types"; + +import { format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; + +export default function CreatedAt({ row, column }: IHeavyCellProps) { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const value = row[tableSettings.auditFieldCreatedBy || "_createdBy"]; + + if (!value || !value.timestamp) return null; + const dateLabel = format( + value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, + column.config?.format || DATE_TIME_FORMAT + ); + + return ( + {dateLabel} + ); +} diff --git a/src/components/fields/CreatedAt/index.tsx b/src/components/fields/CreatedAt/index.tsx new file mode 100644 index 00000000..eaad5800 --- /dev/null +++ b/src/components/fields/CreatedAt/index.tsx @@ -0,0 +1,36 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import CreatedAtIcon from "@src/assets/icons/CreatedAt"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-CreatedAt" */) +); +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-CreatedAt" */ + ) +); +const Settings = lazy( + () => + import("../CreatedBy/Settings" /* webpackChunkName: "Settings-CreatedBy" */) +); + +export const config: IFieldConfig = { + type: FieldType.createdAt, + name: "Created At", + group: "Auditing", + dataType: "firebase.firestore.Timestamp", + initialValue: null, + icon: , + description: "Displays the timestamp of when the row was created. Read-only.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: withSideDrawerEditor(TableCell), + SideDrawerField, + settings: Settings, +}; +export default config; diff --git a/src/components/fields/CreatedBy/Settings.tsx b/src/components/fields/CreatedBy/Settings.tsx new file mode 100644 index 00000000..6efd222d --- /dev/null +++ b/src/components/fields/CreatedBy/Settings.tsx @@ -0,0 +1,46 @@ +import { ISettingsProps } from "@src/components/fields/types"; + +import { Typography, Link } from "@mui/material"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; + +import MultiSelect from "@rowy/multiselect"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { EXTERNAL_LINKS } from "@src/constants/externalLinks"; + +export default function Settings({ onChange, config }: ISettingsProps) { + return ( + <> + ( + {option.label} + )} + label="Display format" + multiple={false} + freeText + clearable={false} + searchable={false} + value={config.format ?? DATE_TIME_FORMAT} + onChange={onChange("format")} + TextFieldProps={{ + helperText: ( + + Date format reference + + + ), + }} + /> + + ); +} diff --git a/src/components/fields/CreatedBy/SideDrawerField.tsx b/src/components/fields/CreatedBy/SideDrawerField.tsx new file mode 100644 index 00000000..61c85914 --- /dev/null +++ b/src/components/fields/CreatedBy/SideDrawerField.tsx @@ -0,0 +1,57 @@ +import { useWatch } from "react-hook-form"; +import { useAtom } from "jotai"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { Box, Stack, Typography, Avatar } from "@mui/material"; +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; + +import { format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; + +export default function CreatedBy({ control, column }: ISideDrawerFieldProps) { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + + const value = useWatch({ + control, + name: tableSettings.auditFieldCreatedBy || "_createdBy", + }); + + if (!value || !value.displayName || !value.timestamp) + return ; + + const dateLabel = format( + value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, + column.config?.format || DATE_TIME_FORMAT + ); + + return ( + + + {value.displayName[0]} + + + + {value.displayName} ({value.email}) + + Created at {dateLabel} + + + + ); +} diff --git a/src/components/fields/CreatedBy/TableCell.tsx b/src/components/fields/CreatedBy/TableCell.tsx new file mode 100644 index 00000000..c5facf0e --- /dev/null +++ b/src/components/fields/CreatedBy/TableCell.tsx @@ -0,0 +1,39 @@ +import { useAtom } from "jotai"; +import { IHeavyCellProps } from "@src/components/fields/types"; + +import { Tooltip, Stack, Avatar } from "@mui/material"; + +import { format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; + +export default function CreatedBy({ row, column }: IHeavyCellProps) { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const value = row[tableSettings.auditFieldCreatedBy || "_createdBy"]; + + if (!value || !value.displayName || !value.timestamp) return null; + const dateLabel = format( + value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, + column.config?.format || DATE_TIME_FORMAT + ); + + return ( + + + + {value.displayName[0]} + + {value.displayName} + + + ); +} diff --git a/src/components/fields/CreatedBy/index.tsx b/src/components/fields/CreatedBy/index.tsx new file mode 100644 index 00000000..52222b39 --- /dev/null +++ b/src/components/fields/CreatedBy/index.tsx @@ -0,0 +1,37 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import CreatedByIcon from "@src/assets/icons/CreatedBy"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-CreatedBy" */) +); +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-CreatedBy" */ + ) +); +const Settings = lazy( + () => import("./Settings" /* webpackChunkName: "Settings-CreatedBy" */) +); + +export const config: IFieldConfig = { + type: FieldType.createdBy, + name: "Created By", + group: "Auditing", + dataType: + "{ displayName: string; email: string; emailVerified: boolean; isAnonymous: boolean; photoURL: string; uid: string; timestamp: firebase.firestore.Timestamp; }", + initialValue: null, + icon: , + description: + "Displays the user that created the row and timestamp. Read-only.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: withSideDrawerEditor(TableCell), + SideDrawerField, + settings: Settings, +}; +export default config; diff --git a/src/components/fields/Date/BasicCell.tsx b/src/components/fields/Date/BasicCell.tsx new file mode 100644 index 00000000..11181f9a --- /dev/null +++ b/src/components/fields/Date/BasicCell.tsx @@ -0,0 +1,25 @@ +import { IBasicCellProps } from "@src/components/fields/types"; +import { isFunction, isDate } from "lodash-es"; +import { format } from "date-fns"; +import { DATE_FORMAT } from "@src/constants/dates"; + +export default function Date_({ + value, + format: formatProp, +}: IBasicCellProps & { format?: string }) { + if ((!!value && isFunction(value.toDate)) || isDate(value)) { + try { + const formatted = format( + isDate(value) ? value : value.toDate(), + formatProp || DATE_FORMAT + ); + return ( + {formatted} + ); + } catch (e) { + return null; + } + } + + return null; +} diff --git a/src/components/fields/Date/Filter.tsx b/src/components/fields/Date/Filter.tsx new file mode 100644 index 00000000..c9681bba --- /dev/null +++ b/src/components/fields/Date/Filter.tsx @@ -0,0 +1,35 @@ +import { IFilterOperator } from "@src/components/fields/types"; + +export const filterOperators: IFilterOperator[] = [ + { + label: "on", + value: "==", + }, + { + value: "!=", + label: "not equal to", + }, + { + label: "before", + value: "<", + }, + { + label: "after", + value: ">", + }, + { + value: "<=", + label: "before or on", + }, + { + value: ">=", + label: "on or after", + }, +]; + +export const valueFormatter = (value: any) => { + if (value && value.toDate) { + return value.toDate(); + } + return null; +}; diff --git a/src/components/fields/Date/Settings.tsx b/src/components/fields/Date/Settings.tsx new file mode 100644 index 00000000..635d749a --- /dev/null +++ b/src/components/fields/Date/Settings.tsx @@ -0,0 +1,40 @@ +import { ISettingsProps } from "@src/components/fields/types"; + +import { Typography, Link } from "@mui/material"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; + +import MultiSelect from "@rowy/multiselect"; +import { DATE_FORMAT } from "@src/constants/dates"; +import { EXTERNAL_LINKS } from "@src/constants/externalLinks"; + +export default function Settings({ onChange, config }: ISettingsProps) { + return ( + <> + ( + {option.label} + )} + label="Display format" + multiple={false} + freeText + clearable={false} + searchable={false} + value={config.format ?? DATE_FORMAT} + onChange={onChange("format")} + TextFieldProps={{ + helperText: ( + + Date format reference + + + ), + }} + /> + + ); +} diff --git a/src/components/fields/Date/SideDrawerField.tsx b/src/components/fields/Date/SideDrawerField.tsx new file mode 100644 index 00000000..7999ab87 --- /dev/null +++ b/src/components/fields/Date/SideDrawerField.tsx @@ -0,0 +1,68 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import DatePicker from "@mui/lab/DatePicker"; +import { TextField } from "@mui/material"; + +import { transformValue, sanitizeValue } from "./utils"; +import { DATE_FORMAT } from "@src/constants/dates"; +import { DateIcon } from "."; + +export interface IDateProps extends ISideDrawerFieldProps {} + +export default function Date_({ column, control }: IDateProps) { + const format = column.config?.format ?? DATE_FORMAT; + + return ( + { + const transformedValue = transformValue(value); + + const handleChange = (date: Date | null) => { + const sanitized = sanitizeValue(date); + if (sanitized === undefined) return; + onChange(sanitized); + }; + + return ( + ( + + ), + }} + sx={{ + "& .MuiInputBase-input": { + fontVariantNumeric: "tabular-nums", + }, + "& .MuiInputAdornment-root": { m: 0 }, + }} + // Touch mode: make the whole field clickable + onClick={props.inputProps?.onClick as any} + /> + )} + label={column.name} + value={transformedValue} + onChange={handleChange} + inputFormat={format} + mask={format.replace(/[A-Za-z]/g, "_")} + clearable + OpenPickerButtonProps={{ size: "small" }} + components={{ OpenPickerIcon: DateIcon }} + disableOpenPicker={false} + /> + ); + }} + /> + ); +} diff --git a/src/components/fields/Date/TableCell.tsx b/src/components/fields/Date/TableCell.tsx new file mode 100644 index 00000000..289755f0 --- /dev/null +++ b/src/components/fields/Date/TableCell.tsx @@ -0,0 +1,116 @@ +import { useDebouncedCallback } from "use-debounce"; +import { IHeavyCellProps } from "@src/components/fields/types"; + +import DatePicker from "@mui/lab/DatePicker"; +import { TextField } from "@mui/material"; + +import { transformValue, sanitizeValue } from "./utils"; +import { DATE_FORMAT } from "@src/constants/dates"; +import BasicCell from "./BasicCell"; +import { DateIcon } from "."; + +export default function Date_({ + column, + value, + disabled, + onSubmit, +}: IHeavyCellProps) { + const format = column.config?.format ?? DATE_FORMAT; + const transformedValue = transformValue(value); + + const handleDateChange = useDebouncedCallback((date: Date | null) => { + const sanitized = sanitizeValue(date); + if (sanitized === undefined) return; + onSubmit(sanitized); + }, 500); + + if (disabled) + return ( + + ); + + return ( + ( + + ), + }} + className="cell-collapse-padding" + sx={{ + width: "100%", + height: "100%", + + "& .MuiInputBase-root": { + height: "100%", + font: "inherit", // Prevent text jumping + letterSpacing: "inherit", // Prevent text jumping + + ".rdg-cell &": { + background: "none !important", + boxShadow: "none", + borderRadius: 0, + padding: 0, + + "&::after": { width: "100%", left: 0 }, + }, + }, + "& .MuiInputBase-input": { + height: "100%", + font: "inherit", // Prevent text jumping + letterSpacing: "inherit", // Prevent text jumping + fontVariantNumeric: "tabular-nums", + + ".rdg-cell &": { + padding: "var(--cell-padding)", + pr: 0, + pb: 1 / 8, + }, + }, + "& .MuiInputAdornment-root": { m: 0 }, + }} + // Prevent react-data-grid showing NullEditor, which unmounts this field + onDoubleClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + // Touch mode: make the whole field clickable + onClick={props.inputProps?.onClick as any} + /> + )} + label={column.name} + value={transformedValue} + onChange={handleDateChange} + inputFormat={format} + mask={format.replace(/[A-Za-z]/g, "_")} + clearable + OpenPickerButtonProps={{ + size: "small", + className: "row-hover-iconButton", + edge: false, + sx: { mr: 0.5 }, + }} + components={{ OpenPickerIcon: DateIcon }} + disableOpenPicker={false} + /> + ); +} diff --git a/src/components/fields/Date/index.tsx b/src/components/fields/Date/index.tsx new file mode 100644 index 00000000..2078ef4a --- /dev/null +++ b/src/components/fields/Date/index.tsx @@ -0,0 +1,47 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import { parse, format } from "date-fns"; +import { DATE_FORMAT } from "@src/constants/dates"; + +import DateIcon from "@mui/icons-material/TodayOutlined"; +import BasicCell from "./BasicCell"; +import NullEditor from "@src/components/Table/editors/NullEditor"; +import { filterOperators, valueFormatter } from "./Filter"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-Date" */) +); +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Date" */) +); +const Settings = lazy( + () => import("./Settings" /* webpackChunkName: "Settings-Date" */) +); + +export const config: IFieldConfig = { + type: FieldType.date, + name: "Date", + group: "Date & Time", + dataType: "firebase.firestore.Timestamp", + initialValue: null, + initializable: true, + icon: , + description: `Formatted date. Format is configurable, default: ${DATE_FORMAT}. Edited with a visual picker.`, + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: NullEditor as any, + SideDrawerField, + filter: { + operators: filterOperators, + valueFormatter, + }, + settings: Settings, + csvImportParser: (value, config) => + parse(value, config?.format ?? DATE_FORMAT, new Date()), + csvExportFormatter: (value: any, config?: any) => + format(value.toDate(), config?.format ?? DATE_FORMAT), +}; +export default config; + +export { DateIcon }; diff --git a/src/components/fields/Date/utils.ts b/src/components/fields/Date/utils.ts new file mode 100644 index 00000000..02c366f2 --- /dev/null +++ b/src/components/fields/Date/utils.ts @@ -0,0 +1,11 @@ +export const transformValue = (value: any) => { + if (typeof value === "number") return new Date(value); + if (value && "toDate" in value) return value.toDate(); + if (value !== undefined) return value; + return null; +}; + +export const sanitizeValue = (value: Date | null) => { + if (isNaN(value?.valueOf() ?? 0)) return undefined; + return value; +}; diff --git a/src/components/fields/DateTime/BasicCell.tsx b/src/components/fields/DateTime/BasicCell.tsx new file mode 100644 index 00000000..6c4bac2f --- /dev/null +++ b/src/components/fields/DateTime/BasicCell.tsx @@ -0,0 +1,25 @@ +import { IBasicCellProps } from "@src/components/fields/types"; +import { isFunction, isDate } from "lodash-es"; +import { format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; + +export default function DateTime({ + value, + format: formatProp, +}: IBasicCellProps & { format?: string }) { + if ((!!value && isFunction(value.toDate)) || isDate(value)) { + try { + const formatted = format( + isDate(value) ? value : value.toDate(), + formatProp || DATE_TIME_FORMAT + ); + return ( + {formatted} + ); + } catch (e) { + return null; + } + } + + return null; +} diff --git a/src/components/fields/DateTime/Filter.tsx b/src/components/fields/DateTime/Filter.tsx new file mode 100644 index 00000000..ef3332ad --- /dev/null +++ b/src/components/fields/DateTime/Filter.tsx @@ -0,0 +1,35 @@ +import { IFilterOperator } from "@src/components/fields/types"; + +export const filterOperators: IFilterOperator[] = [ + { + label: "at", + value: "==", + }, + { + value: "!=", + label: "not equal to", + }, + { + label: "before", + value: "<", + }, + { + label: "after", + value: ">", + }, + { + value: "<=", + label: "before or at", + }, + { + value: ">=", + label: "at or after", + }, +]; + +export const valueFormatter = (value: any) => { + if (value && value.toDate) { + return value.toDate(); + } + return null; +}; diff --git a/src/components/fields/DateTime/Settings.tsx b/src/components/fields/DateTime/Settings.tsx new file mode 100644 index 00000000..6efd222d --- /dev/null +++ b/src/components/fields/DateTime/Settings.tsx @@ -0,0 +1,46 @@ +import { ISettingsProps } from "@src/components/fields/types"; + +import { Typography, Link } from "@mui/material"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; + +import MultiSelect from "@rowy/multiselect"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { EXTERNAL_LINKS } from "@src/constants/externalLinks"; + +export default function Settings({ onChange, config }: ISettingsProps) { + return ( + <> + ( + {option.label} + )} + label="Display format" + multiple={false} + freeText + clearable={false} + searchable={false} + value={config.format ?? DATE_TIME_FORMAT} + onChange={onChange("format")} + TextFieldProps={{ + helperText: ( + + Date format reference + + + ), + }} + /> + + ); +} diff --git a/src/components/fields/DateTime/SideDrawerField.tsx b/src/components/fields/DateTime/SideDrawerField.tsx new file mode 100644 index 00000000..51a32f16 --- /dev/null +++ b/src/components/fields/DateTime/SideDrawerField.tsx @@ -0,0 +1,72 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import DatePicker from "@mui/lab/DatePicker"; +import { TextField } from "@mui/material"; + +import { + transformValue, + sanitizeValue, +} from "@src/components/fields/Date/utils"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { DateTimeIcon } from "."; + +export interface IDateProps extends ISideDrawerFieldProps {} + +export default function Date_({ column, control }: IDateProps) { + const format = column.config?.format ?? DATE_TIME_FORMAT; + + return ( + { + const transformedValue = transformValue(value); + + const handleChange = (date: Date | null) => { + const sanitized = sanitizeValue(date); + if (sanitized === undefined) return; + onChange(sanitized); + }; + + return ( + ( + + ), + }} + sx={{ + "& .MuiInputBase-input": { + fontVariantNumeric: "tabular-nums", + }, + "& .MuiInputAdornment-root": { m: 0 }, + }} + // Touch mode: make the whole field clickable + onClick={props.inputProps?.onClick as any} + /> + )} + label={column.name} + value={transformedValue} + onChange={handleChange} + inputFormat={format} + mask={format.replace(/[A-Za-z]/g, "_")} + clearable + OpenPickerButtonProps={{ size: "small" }} + components={{ OpenPickerIcon: DateTimeIcon }} + disableOpenPicker={false} + showToolbar + /> + ); + }} + /> + ); +} diff --git a/src/components/fields/DateTime/TableCell.tsx b/src/components/fields/DateTime/TableCell.tsx new file mode 100644 index 00000000..32124a32 --- /dev/null +++ b/src/components/fields/DateTime/TableCell.tsx @@ -0,0 +1,121 @@ +import { useDebouncedCallback } from "use-debounce"; +import { IHeavyCellProps } from "@src/components/fields/types"; + +import DateTimePicker from "@mui/lab/DateTimePicker"; +import { TextField } from "@mui/material"; + +import { + transformValue, + sanitizeValue, +} from "@src/components/fields/Date/utils"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import BasicCell from "./BasicCell"; +import { DateTimeIcon } from "."; + +export default function DateTime({ + column, + value, + disabled, + onSubmit, +}: IHeavyCellProps) { + const transformedValue = transformValue(value); + + const handleDateChange = useDebouncedCallback((date: Date | null) => { + const sanitized = sanitizeValue(date); + if (sanitized === undefined) return; + onSubmit(sanitized); + }, 500); + + const format = column.config?.format ?? DATE_TIME_FORMAT; + + if (disabled) + return ( + + ); + + return ( + ( + + ), + }} + className="cell-collapse-padding" + sx={{ + width: "100%", + height: "100%", + + "& .MuiInputBase-root": { + height: "100%", + font: "inherit", // Prevent text jumping + letterSpacing: "inherit", // Prevent text jumping + + ".rdg-cell &": { + background: "none !important", + boxShadow: "none", + borderRadius: 0, + padding: 0, + + "&::after": { width: "100%", left: 0 }, + }, + }, + "& .MuiInputBase-input": { + height: "100%", + font: "inherit", // Prevent text jumping + letterSpacing: "inherit", // Prevent text jumping + fontVariantNumeric: "tabular-nums", + + ".rdg-cell &": { + padding: "var(--cell-padding)", + pr: 0, + pb: 1 / 8, + }, + }, + "& .MuiInputAdornment-root": { m: 0 }, + }} + // Prevent react-data-grid showing NullEditor, which unmounts this field + onDoubleClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + // Touch mode: make the whole field clickable + onClick={props.inputProps?.onClick as any} + /> + )} + label={column.name} + value={transformedValue} + onChange={handleDateChange} + inputFormat={format} + mask={format.replace(/[A-Za-z]/g, "_")} + clearable + OpenPickerButtonProps={{ + size: "small", + className: "row-hover-iconButton", + edge: false, + sx: { mr: 0.5 }, + }} + components={{ OpenPickerIcon: DateTimeIcon }} + disableOpenPicker={false} + showToolbar + /> + ); +} diff --git a/src/components/fields/DateTime/index.tsx b/src/components/fields/DateTime/index.tsx new file mode 100644 index 00000000..07e5b19e --- /dev/null +++ b/src/components/fields/DateTime/index.tsx @@ -0,0 +1,48 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import { parseJSON, format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; + +import DateTimeIcon from "@mui/icons-material/AccessTime"; +import BasicCell from "./BasicCell"; +import NullEditor from "@src/components/Table/editors/NullEditor"; +import { filterOperators, valueFormatter } from "./Filter"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-DateTime" */) +); +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-DateTime" */ + ) +); +const Settings = lazy( + () => import("./Settings" /* webpackChunkName: "Settings-DateTime" */) +); + +export const config: IFieldConfig = { + type: FieldType.dateTime, + name: "Date & Time", + group: "Date & Time", + dataType: "firebase.firestore.Timestamp", + initialValue: null, + initializable: true, + icon: , + description: `Formatted date & time. Format is configurable, default: ${DATE_TIME_FORMAT}. Edited with a visual picker.`, + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: NullEditor as any, + SideDrawerField, + filter: { + operators: filterOperators, + valueFormatter, + }, + settings: Settings, + csvImportParser: (value) => parseJSON(value).getTime(), + csvExportFormatter: (value: any, config?: any) => + format(value.toDate(), config?.format ?? DATE_TIME_FORMAT), +}; +export default config; + +export { DateTimeIcon }; diff --git a/src/components/fields/Duration/SideDrawerField.tsx b/src/components/fields/Duration/SideDrawerField.tsx new file mode 100644 index 00000000..61ff4be9 --- /dev/null +++ b/src/components/fields/Duration/SideDrawerField.tsx @@ -0,0 +1,31 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { Box } from "@mui/material"; +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; +import { getDurationString } from "./utils"; + +export default function Duration({ column, control }: ISideDrawerFieldProps) { + return ( + { + if ( + !value || + !value.start || + !("toDate" in value.start) || + !value.end || + !("toDate" in value.end) + ) + return ; + + return ( + + {getDurationString(value.start.toDate(), value.end.toDate())} + + ); + }} + /> + ); +} diff --git a/src/components/fields/Duration/TableCell.tsx b/src/components/fields/Duration/TableCell.tsx new file mode 100644 index 00000000..b42b6cf1 --- /dev/null +++ b/src/components/fields/Duration/TableCell.tsx @@ -0,0 +1,16 @@ +import { IHeavyCellProps } from "@src/components/fields/types"; + +import { getDurationString } from "./utils"; + +export default function Duration({ value }: IHeavyCellProps) { + if ( + !value || + !value.start || + !("toDate" in value.start) || + !value.end || + !("toDate" in value.end) + ) + return null; + + return <>{getDurationString(value.start.toDate(), value.end.toDate())}; +} diff --git a/src/components/fields/Duration/index.tsx b/src/components/fields/Duration/index.tsx new file mode 100644 index 00000000..81148303 --- /dev/null +++ b/src/components/fields/Duration/index.tsx @@ -0,0 +1,31 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import DurationIcon from "@mui/icons-material/TimerOutlined"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import NullEditor from "@src/components/Table/editors/NullEditor"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-Duration" */) +); +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-Duration" */ + ) +); + +export const config: IFieldConfig = { + type: FieldType.duration, + name: "Duration (Alpha)", + group: "Date & Time", + dataType: "Record<'start' | 'end', firebase.firestore.Timestamp>", + initialValue: {}, + icon: , + description: "Duration calculated from two timestamps.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: NullEditor as any, + SideDrawerField, +}; +export default config; diff --git a/src/components/fields/Duration/utils.ts b/src/components/fields/Duration/utils.ts new file mode 100644 index 00000000..97f1afd7 --- /dev/null +++ b/src/components/fields/Duration/utils.ts @@ -0,0 +1,12 @@ +export const getDurationString = (start: Date, end: Date) => { + let distance = Math.abs(end.getTime() - start.getTime()); + const hours = Math.floor(distance / 3600000); + distance -= hours * 3600000; + const minutes = Math.floor(distance / 60000); + distance -= minutes * 60000; + const seconds = Math.floor(distance / 1000); + + return `${hours ? `${hours}h` : ""} ${("0" + minutes).slice(-2)}m ${( + "0" + seconds + ).slice(-2)}s`; +}; diff --git a/src/components/fields/Email/SideDrawerField.tsx b/src/components/fields/Email/SideDrawerField.tsx new file mode 100644 index 00000000..12cca4e1 --- /dev/null +++ b/src/components/fields/Email/SideDrawerField.tsx @@ -0,0 +1,37 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { TextField } from "@mui/material"; + +export default function Email({ + control, + column, + disabled, +}: ISideDrawerFieldProps) { + return ( + { + return ( + + ); + }} + /> + ); +} diff --git a/src/components/fields/Email/index.tsx b/src/components/fields/Email/index.tsx new file mode 100644 index 00000000..87b3a07a --- /dev/null +++ b/src/components/fields/Email/index.tsx @@ -0,0 +1,33 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; + +import EmailIcon from "@mui/icons-material/MailOutlined"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellValue"; +import TextEditor from "@src/components/Table/editors/TextEditor"; +import { filterOperators } from "@src/components/fields/ShortText/Filter"; +import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; + +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Email" */) +); + +export const config: IFieldConfig = { + type: FieldType.email, + name: "Email", + group: "Text", + dataType: "string", + initialValue: "", + initializable: true, + icon: , + description: "Email address. Not validated.", + contextMenuActions: BasicContextMenuActions, + TableCell: withBasicCell(BasicCell), + TableEditor: TextEditor, + SideDrawerField, + filter: { + operators: filterOperators, + }, +}; +export default config; diff --git a/src/components/fields/File/SideDrawerField.tsx b/src/components/fields/File/SideDrawerField.tsx new file mode 100644 index 00000000..788afa54 --- /dev/null +++ b/src/components/fields/File/SideDrawerField.tsx @@ -0,0 +1,187 @@ +import { useCallback, useState } from "react"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; +import { useSetAtom } from "jotai"; +import { Controller } from "react-hook-form"; +import { format } from "date-fns"; + +import { useDropzone } from "react-dropzone"; +import useUploader, { FileValue } from "@src/hooks/useFirebaseStorageUploader"; + +import { + alpha, + ButtonBase, + Typography, + Grid, + Tooltip, + Chip, +} from "@mui/material"; +import UploadIcon from "@src/assets/icons/Upload"; +import { FileIcon } from "."; + +import CircularProgressOptical from "@src/components/CircularProgressOptical"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; + +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; +import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope"; +import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; + +interface IControlledFileUploaderProps + extends Pick { + onChange: (value: any) => void; + value: any; +} + +function ControlledFileUploader({ + onChange, + + value, + column, + docRef, + disabled, +}: IControlledFileUploaderProps) { + const confirm = useSetAtom(confirmDialogAtom, globalScope); + const updateField = useSetAtom(updateFieldAtom, tableScope); + + const { uploaderState, upload, deleteUpload } = useUploader(); + + // Store a preview image locally while uploading + const [localFile, setLocalFile] = useState(""); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + + if (docRef && file) { + upload({ + docRef: docRef as any, + fieldName: column.key, + files: [file], + previousValue: value ?? [], + onComplete: (newValue) => { + updateField({ + path: docRef.path, + fieldName: column.key, + value: newValue, + }); + onChange(newValue); + setLocalFile(""); + }, + }); + setLocalFile(file.name); + } + }, + [docRef, value] + ); + + const handleDelete = (index: number) => { + const newValue = [...value]; + const toBeDeleted = newValue.splice(index, 1); + toBeDeleted.length && deleteUpload(toBeDeleted[0]); + onChange(newValue); + }; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + multiple: false, + }); + + return ( + <> + {!disabled && ( + + alpha( + theme.palette.primary.light, + theme.palette.action.hoverOpacity * 2 + ), + color: "primary.main", + } + : {}, + ]} + {...getRootProps()} + > + + + Click to upload or drop file here + + + + )} + + + {Array.isArray(value) && + value.map((file: FileValue, i) => ( + + +
+ } + label={file.name} + onClick={() => window.open(file.downloadURL)} + onDelete={ + !disabled + ? () => + confirm({ + title: "Delete file?", + body: "This file cannot be recovered after", + confirm: "Delete", + confirmColor: "error", + handleConfirm: () => handleDelete(i), + }) + : undefined + } + /> +
+
+
+ ))} + + {localFile && ( + + } + label={localFile} + deleteIcon={} + /> + + )} +
+ + ); +} + +export default function File_({ + control, + column, + disabled, + docRef, +}: ISideDrawerFieldProps) { + return ( + ( + + )} + /> + ); +} diff --git a/src/components/fields/File/TableCell.tsx b/src/components/fields/File/TableCell.tsx new file mode 100644 index 00000000..50ea1328 --- /dev/null +++ b/src/components/fields/File/TableCell.tsx @@ -0,0 +1,166 @@ +import { useCallback } from "react"; +import { IHeavyCellProps } from "@src/components/fields/types"; +import { useSetAtom } from "jotai"; +import { findIndex } from "lodash-es"; + +import { useDropzone } from "react-dropzone"; +import { format } from "date-fns"; + +import { alpha, Stack, Grid, Tooltip, Chip, IconButton } from "@mui/material"; +import UploadIcon from "@src/assets/icons/Upload"; +import ChipList from "@src/components/Table/formatters/ChipList"; +import CircularProgressOptical from "@src/components/CircularProgressOptical"; + +import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope"; +import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; +import useUploader, { FileValue } from "@src/hooks/useFirebaseStorageUploader"; +import { FileIcon } from "."; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; + +export default function File_({ + column, + row, + value, + onSubmit, + disabled, +}: IHeavyCellProps) { + const confirm = useSetAtom(confirmDialogAtom, globalScope); + const updateField = useSetAtom(updateFieldAtom, tableScope); + + const { uploaderState, upload, deleteUpload } = useUploader(); + const { progress, isLoading } = uploaderState; + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + + if (file) { + upload({ + docRef: row.ref, + fieldName: column.key, + files: [file], + previousValue: value, + onComplete: (newValue) => { + updateField({ + path: row._rowy_ref.path, + fieldName: column.key, + value: newValue, + }); + }, + }); + } + }, + [value] + ); + + const handleDelete = (ref: string) => { + const newValue = [...value]; + const index = findIndex(newValue, ["ref", ref]); + const toBeDeleted = newValue.splice(index, 1); + toBeDeleted.length && deleteUpload(toBeDeleted[0]); + onSubmit(newValue); + }; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + multiple: false, + }); + + const dropzoneProps = getRootProps(); + + return ( + + alpha( + theme.palette.primary.main, + theme.palette.action.hoverOpacity * 2 + ), + + "& .row-hover-iconButton": { color: "primary.main" }, + } + : {}), + }} + {...dropzoneProps} + onClick={undefined} + > + + {Array.isArray(value) && + value.map((file: FileValue) => ( + 1 ? { maxWidth: `calc(100% - 12px)` } : {} + } + > + + } + label={file.name} + onClick={(e) => { + window.open(file.downloadURL); + e.stopPropagation(); + }} + onDelete={ + disabled + ? undefined + : () => + confirm({ + handleConfirm: () => handleDelete(file.ref), + title: "Delete file?", + body: "This file cannot be recovered after", + confirm: "Delete", + confirmColor: "error", + }) + } + style={{ width: "100%" }} + /> + + + ))} + + + {!isLoading ? ( + !disabled && ( + { + dropzoneProps.onClick!(e); + e.stopPropagation(); + }} + style={{ display: "flex" }} + > + + + ) + ) : ( +
+ +
+ )} + + +
+ ); +} diff --git a/src/components/fields/File/index.tsx b/src/components/fields/File/index.tsx new file mode 100644 index 00000000..bdd0a323 --- /dev/null +++ b/src/components/fields/File/index.tsx @@ -0,0 +1,32 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import FileIcon from "@mui/icons-material/AttachFile"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import NullEditor from "@src/components/Table/editors/NullEditor"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-File" */) +); +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-File" */) +); + +export const config: IFieldConfig = { + type: FieldType.file, + name: "File", + group: "File", + dataType: + "{ downloadURL: string; lastModifiedTS: number; name: string; type: string; ref: string; }[]", + initialValue: [], + icon: , + description: "File uploaded to Firebase Storage. Supports any file type.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: NullEditor as any, + SideDrawerField, +}; +export default config; + +export { FileIcon }; diff --git a/src/components/fields/Id/SideDrawerField.tsx b/src/components/fields/Id/SideDrawerField.tsx new file mode 100644 index 00000000..6e3dd97d --- /dev/null +++ b/src/components/fields/Id/SideDrawerField.tsx @@ -0,0 +1,12 @@ +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { Box } from "@mui/material"; +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; + +export default function Id({ docRef }: ISideDrawerFieldProps) { + return ( + + {docRef.id} + + ); +} diff --git a/src/components/fields/Id/TableCell.tsx b/src/components/fields/Id/TableCell.tsx new file mode 100644 index 00000000..fbc845ac --- /dev/null +++ b/src/components/fields/Id/TableCell.tsx @@ -0,0 +1,19 @@ +import { IHeavyCellProps } from "@src/components/fields/types"; + +import { useTheme } from "@mui/material"; + +export default function Id({ docRef }: IHeavyCellProps) { + const theme = useTheme(); + + return ( + + {docRef.id} + + ); +} diff --git a/src/components/fields/Id/index.tsx b/src/components/fields/Id/index.tsx new file mode 100644 index 00000000..fdee6796 --- /dev/null +++ b/src/components/fields/Id/index.tsx @@ -0,0 +1,28 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import IdIcon from "@src/assets/icons/Id"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellValue"; +import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-Id" */) +); +const SideDrawerField = lazy( + () => import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Id" */) +); + +export const config: IFieldConfig = { + type: FieldType.id, + name: "ID", + group: "Metadata", + dataType: "string", + initialValue: "", + icon: , + description: "Displays the row’s ID. Read-only. Cannot be sorted.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: withSideDrawerEditor(TableCell), + SideDrawerField, +}; +export default config; diff --git a/src/components/fields/Image/SideDrawerField.tsx b/src/components/fields/Image/SideDrawerField.tsx new file mode 100644 index 00000000..00dba032 --- /dev/null +++ b/src/components/fields/Image/SideDrawerField.tsx @@ -0,0 +1,286 @@ +import { ISideDrawerFieldProps } from "@src/components/fields/types"; +import { useCallback, useState } from "react"; +import { useSetAtom } from "jotai"; +import { Controller } from "react-hook-form"; + +import { useDropzone } from "react-dropzone"; +// TODO: GENERALIZE +import useUploader from "@src/hooks/useFirebaseStorageUploader"; + +import { + alpha, + ButtonBase, + Typography, + Grid, + Tooltip, + Theme, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/AddAPhotoOutlined"; +import DeleteIcon from "@mui/icons-material/DeleteOutlined"; +import OpenIcon from "@mui/icons-material/OpenInNewOutlined"; + +import Thumbnail from "@src/components/Thumbnail"; +import CircularProgressOptical from "@src/components/CircularProgressOptical"; + +import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope"; +import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; +import { IMAGE_MIME_TYPES } from "."; +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; + +const imgSx = { + position: "relative", + width: 80, + height: 80, + borderRadius: 1, + // boxShadow: `0 0 0 1px ${theme.palette.divider} inset`, + + backgroundSize: "contain", + backgroundPosition: "center center", + backgroundRepeat: "no-repeat", +}; +const thumbnailSx = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", +}; +const overlaySx = { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, + + backgroundColor: (theme: Theme) => alpha(theme.palette.background.paper, 0.8), + color: "text.secondary", + boxShadow: (theme: Theme) => `0 0 0 1px ${theme.palette.divider} inset`, + borderRadius: 1, +}; +const deleteImgHoverSx = { + transition: (theme: Theme) => + theme.transitions.create("background-color", { + duration: theme.transitions.duration.shortest, + }), + + backgroundColor: "transparent", + + "$img:hover &": { + backgroundColor: (theme: Theme) => + alpha(theme.palette.background.paper, 0.8), + "& *": { opacity: 1 }, + }, + + "& *": { + opacity: 0, + transition: (theme: Theme) => + theme.transitions.create("opacity", { + duration: theme.transitions.duration.shortest, + }), + }, +}; + +interface IControlledImageUploaderProps + extends Pick { + onChange: (value: any) => void; + value: any; +} + +function ControlledImageUploader({ + onChange, + value, + column, + docRef, + disabled, +}: IControlledImageUploaderProps) { + const confirm = useSetAtom(confirmDialogAtom, globalScope); + const updateField = useSetAtom(updateFieldAtom, tableScope); + const { uploaderState, upload, deleteUpload } = useUploader(); + const { progress } = uploaderState; + + // Store a preview image locally while uploading + const [localImage, setLocalImage] = useState(""); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + const imageFile = acceptedFiles[0]; + + if (docRef && imageFile) { + upload({ + docRef: docRef as any, + fieldName: column.key, + files: [imageFile], + previousValue: value ?? [], + onComplete: (newValue) => { + updateField({ + path: docRef.path, + fieldName: column.key, + value: newValue, + }); + onChange(newValue); + setLocalImage(""); + }, + }); + setLocalImage(URL.createObjectURL(imageFile)); + } + }, + [docRef, value] + ); + + const handleDelete = (index: number) => { + const newValue = [...value]; + const toBeDeleted = newValue.splice(index, 1); + toBeDeleted.length && deleteUpload(toBeDeleted[0]); + onChange(newValue); + }; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + multiple: false, + accept: IMAGE_MIME_TYPES, + }); + + return ( + <> + {!disabled && ( + + alpha( + theme.palette.primary.light, + theme.palette.action.hoverOpacity * 2 + ), + color: "primary.main", + } + : {}, + ]} + {...getRootProps()} + > + + + {isDragActive + ? "Drop image here" + : "Click to upload or drop image here"} + + + + )} + + + {Array.isArray(value) && + value.map((image, i) => ( + + {disabled ? ( + + window.open(image.downloadURL, "_blank")} + > + + + {disabled ? : } + + + + ) : ( + +
+ + confirm({ + title: "Delete image?", + body: "This image cannot be recovered after", + confirm: "Delete", + confirmColor: "error", + handleConfirm: () => handleDelete(i), + }) + } + > + + + + + +
+
+ )} +
+ ))} + + {localImage && ( + + + + + + + + )} +
+ + ); +} + +export default function Image_({ + control, + column, + disabled, + docRef, +}: ISideDrawerFieldProps) { + return ( + ( + + )} + /> + ); +} diff --git a/src/components/fields/Image/TableCell.tsx b/src/components/fields/Image/TableCell.tsx new file mode 100644 index 00000000..28302b01 --- /dev/null +++ b/src/components/fields/Image/TableCell.tsx @@ -0,0 +1,297 @@ +import { useCallback, useState } from "react"; +import { IHeavyCellProps } from "@src/components/fields/types"; +import { useAtom, useSetAtom } from "jotai"; +import { findIndex } from "lodash-es"; + +import { useDropzone } from "react-dropzone"; + +import { + alpha, + Theme, + Box, + Stack, + Grid, + IconButton, + ButtonBase, + Tooltip, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/AddAPhotoOutlined"; +import DeleteIcon from "@mui/icons-material/DeleteOutlined"; +import OpenIcon from "@mui/icons-material/OpenInNewOutlined"; + +import Thumbnail from "@src/components/Thumbnail"; +import CircularProgressOptical from "@src/components/CircularProgressOptical"; + +import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope"; +import { + tableSchemaAtom, + tableScope, + updateFieldAtom, +} from "@src/atoms/tableScope"; +import useUploader, { FileValue } from "@src/hooks/useFirebaseStorageUploader"; +import { IMAGE_MIME_TYPES } from "./index"; +import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; + +// MULTIPLE +const imgSx = (rowHeight: number) => ({ + position: "relative", + display: "flex", + + width: (theme: Theme) => `calc(${rowHeight}px - ${theme.spacing(1)} - 1px)`, + height: (theme: Theme) => `calc(${rowHeight}px - ${theme.spacing(1)} - 1px)`, + + backgroundSize: "contain", + backgroundPosition: "center center", + backgroundRepeat: "no-repeat", + + borderRadius: 1, +}); +const thumbnailSx = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", +}; +const deleteImgHoverSx = { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, + + color: "text.secondary", + boxShadow: (theme: Theme) => `0 0 0 1px ${theme.palette.divider} inset`, + borderRadius: 1, + + transition: (theme: Theme) => + theme.transitions.create("background-color", { + duration: theme.transitions.duration.shortest, + }), + + "& *": { + opacity: 0, + transition: (theme: Theme) => + theme.transitions.create("opacity", { + duration: theme.transitions.duration.shortest, + }), + }, + + ".img:hover &": { + backgroundColor: (theme: Theme) => + alpha(theme.palette.background.paper, 0.8), + "& *": { opacity: 1 }, + }, +}; + +export default function Image_({ + column, + row, + value, + onSubmit, + disabled, +}: IHeavyCellProps) { + const confirm = useSetAtom(confirmDialogAtom, globalScope); + const updateField = useSetAtom(updateFieldAtom, tableScope); + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const { uploaderState, upload, deleteUpload } = useUploader(); + const { progress, isLoading } = uploaderState; + + // Store a preview image locally while uploading + const [localImage, setLocalImage] = useState(""); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + const imageFile = acceptedFiles[0]; + + if (imageFile) { + upload({ + docRef: row.ref, + fieldName: column.key, + files: [imageFile], + previousValue: value, + onComplete: (newValue) => { + updateField({ + path: row._rowy_ref.path, + fieldName: column.key, + value: newValue, + }); + setLocalImage(""); + }, + }); + setLocalImage(URL.createObjectURL(imageFile)); + } + }, + [value] + ); + + const handleDelete = (ref: string) => () => { + const newValue = [...value]; + const index = findIndex(newValue, ["ref", ref]); + const toBeDeleted = newValue.splice(index, 1); + toBeDeleted.length && deleteUpload(toBeDeleted[0]); + onSubmit(newValue); + }; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + multiple: false, + accept: IMAGE_MIME_TYPES, + }); + + const dropzoneProps = getRootProps(); + + const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT; + let thumbnailSize = "100x100"; + if (rowHeight > 50) thumbnailSize = "200x200"; + if (rowHeight > 100) thumbnailSize = "400x400"; + + return ( + + alpha( + theme.palette.primary.main, + theme.palette.action.hoverOpacity * 2 + ), + + "& .row-hover-iconButton": { color: "primary.main" }, + } + : {}, + ]} + alignItems="center" + {...dropzoneProps} + onClick={undefined} + > +
+ + {Array.isArray(value) && + value.map((file: FileValue) => ( + + {disabled ? ( + + window.open(file.downloadURL, "_blank")} + > + + + {disabled ? ( + + ) : ( + + )} + + + + ) : ( + +
+ { + confirm({ + title: "Delete image?", + body: "This image cannot be recovered after", + confirm: "Delete", + confirmColor: "error", + handleConfirm: handleDelete(file.ref), + }); + }} + > + + + + + +
+
+ )} +
+ ))} + + {localImage && ( + + + `0 0 0 1px ${theme.palette.divider} inset`, + }, + ]} + style={{ backgroundImage: `url("${localImage}")` }} + /> + + )} +
+
+ + {!isLoading ? ( + !disabled && ( + { + dropzoneProps.onClick!(e); + e.stopPropagation(); + }} + style={{ display: "flex" }} + > + + + ) + ) : ( +
+ +
+ )} + + +
+ ); +} diff --git a/src/components/fields/Image/index.tsx b/src/components/fields/Image/index.tsx new file mode 100644 index 00000000..5d238b83 --- /dev/null +++ b/src/components/fields/Image/index.tsx @@ -0,0 +1,40 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import ImageIcon from "@src/assets/icons/Image"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import NullEditor from "@src/components/Table/editors/NullEditor"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-Image" */) +); +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Image" */) +); + +export const config: IFieldConfig = { + type: FieldType.image, + name: "Image", + group: "File", + dataType: "RowyFile[]", + initialValue: [], + icon: , + description: + "Image file uploaded to Firebase Storage. Supports JPEG, PNG, SVG, GIF, WebP, AVIF, JPEG XL.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: NullEditor as any, + SideDrawerField, +}; +export default config; + +export const IMAGE_MIME_TYPES = [ + "image/jpeg", + "image/png", + "image/svg+xml", + "image/gif", + "image/webp", + "image/avif", + "image/jxl", +]; diff --git a/src/components/fields/LongText/BasicCell.tsx b/src/components/fields/LongText/BasicCell.tsx new file mode 100644 index 00000000..14c6e23e --- /dev/null +++ b/src/components/fields/LongText/BasicCell.tsx @@ -0,0 +1,22 @@ +import { IBasicCellProps } from "@src/components/fields/types"; + +import { useTheme } from "@mui/material"; + +export default function LongText({ value }: IBasicCellProps) { + const theme = useTheme(); + + return ( +
+ {value} +
+ ); +} diff --git a/src/components/fields/LongText/SideDrawerField.tsx b/src/components/fields/LongText/SideDrawerField.tsx new file mode 100644 index 00000000..96ed5403 --- /dev/null +++ b/src/components/fields/LongText/SideDrawerField.tsx @@ -0,0 +1,36 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { TextField } from "@mui/material"; + +export default function LongText({ + control, + column, + disabled, +}: ISideDrawerFieldProps) { + return ( + { + return ( + + ); + }} + /> + ); +} diff --git a/src/components/fields/LongText/index.tsx b/src/components/fields/LongText/index.tsx new file mode 100644 index 00000000..2c501ce6 --- /dev/null +++ b/src/components/fields/LongText/index.tsx @@ -0,0 +1,35 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; + +import LongTextIcon from "@mui/icons-material/Notes"; +import BasicCell from "./BasicCell"; +import TextEditor from "@src/components/Table/editors/TextEditor"; +import { filterOperators } from "@src/components/fields/ShortText/Filter"; +import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; + +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-LongText" */ + ) +); + +export const config: IFieldConfig = { + type: FieldType.longText, + name: "Long Text", + group: "Text", + dataType: "string", + initialValue: "", + initializable: true, + icon: , + description: "Text displayed on multiple lines.", + contextMenuActions: BasicContextMenuActions, + TableCell: withBasicCell(BasicCell), + TableEditor: TextEditor, + SideDrawerField, + filter: { + operators: filterOperators, + }, +}; +export default config; diff --git a/src/components/fields/MultiSelect/ConvertStringToArray.tsx b/src/components/fields/MultiSelect/ConvertStringToArray.tsx new file mode 100644 index 00000000..9d3f3ce0 --- /dev/null +++ b/src/components/fields/MultiSelect/ConvertStringToArray.tsx @@ -0,0 +1,29 @@ +import { IPopoverInlineCellProps } from "@src/components/fields/types"; +import { Grid, Tooltip, Button } from "@mui/material"; + +export const ConvertStringToArray = ({ + value, + onSubmit, +}: Pick) => ( + + + {value} + + + + + + + +); diff --git a/src/components/fields/MultiSelect/Filter.ts b/src/components/fields/MultiSelect/Filter.ts new file mode 100644 index 00000000..585147b5 --- /dev/null +++ b/src/components/fields/MultiSelect/Filter.ts @@ -0,0 +1,20 @@ +import { IFilterOperator } from "@src/components/fields/types"; + +export const filterOperators: IFilterOperator[] = [ + { + value: "==", + label: "is equal to", + }, + { + value: "!=", + label: "not equal to", + }, + { + value: "array-contains", + label: "contains", + }, + { + value: "array-contains-any", + label: "has any", + }, +]; diff --git a/src/components/fields/MultiSelect/InlineCell.tsx b/src/components/fields/MultiSelect/InlineCell.tsx new file mode 100644 index 00000000..748e3c18 --- /dev/null +++ b/src/components/fields/MultiSelect/InlineCell.tsx @@ -0,0 +1,61 @@ +import { forwardRef } from "react"; +import { IPopoverInlineCellProps } from "@src/components/fields/types"; + +import { ButtonBase, Grid } from "@mui/material"; +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; + +import { sanitiseValue } from "./utils"; +import ChipList from "@src/components/Table/formatters/ChipList"; +import FormattedChip from "@src/components/FormattedChip"; +import { ConvertStringToArray } from "./ConvertStringToArray"; + +export const MultiSelect = forwardRef(function MultiSelect( + { value, showPopoverCell, disabled, onSubmit }: IPopoverInlineCellProps, + ref: React.Ref +) { + if (typeof value === "string" && value !== "") + return ; + + return ( + showPopoverCell(true)} + ref={ref} + disabled={disabled} + className="cell-collapse-padding" + sx={{ + height: "100%", + font: "inherit", + color: "inherit !important", + letterSpacing: "inherit", + textAlign: "inherit", + justifyContent: "flex-start", + }} + > + + {sanitiseValue(value).map( + (item) => + typeof item === "string" && ( + + + + ) + )} + + + {!disabled && ( + + )} + + ); +}); + +export default MultiSelect; diff --git a/src/components/fields/MultiSelect/PopoverCell.tsx b/src/components/fields/MultiSelect/PopoverCell.tsx new file mode 100644 index 00000000..0d54d723 --- /dev/null +++ b/src/components/fields/MultiSelect/PopoverCell.tsx @@ -0,0 +1,41 @@ +import { IPopoverCellProps } from "@src/components/fields/types"; + +import MultiSelect_ from "@rowy/multiselect"; + +import { sanitiseValue } from "./utils"; + +export default function MultiSelect({ + value, + onSubmit, + column, + parentRef, + showPopoverCell, + disabled, +}: IPopoverCellProps) { + const config = column.config ?? {}; + + return ( + showPopoverCell(false)} + /> + ); +} diff --git a/src/components/fields/MultiSelect/SideDrawerField.tsx b/src/components/fields/MultiSelect/SideDrawerField.tsx new file mode 100644 index 00000000..adc979d0 --- /dev/null +++ b/src/components/fields/MultiSelect/SideDrawerField.tsx @@ -0,0 +1,69 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { Grid } from "@mui/material"; +import MultiSelectComponent from "@rowy/multiselect"; +import FormattedChip from "@src/components/FormattedChip"; + +import { sanitiseValue } from "./utils"; +import { ConvertStringToArray } from "./ConvertStringToArray"; + +export default function MultiSelect({ + column, + control, + disabled, +}: ISideDrawerFieldProps) { + const config = column.config ?? {}; + + return ( + { + const handleDelete = (index: number) => () => { + const newValues = [...value]; + newValues.splice(index, 1); + onChange(newValues); + }; + + if (typeof value === "string" && value !== "") + return ; + + return ( + <> + + + {value && Array.isArray(value) && ( + + {value.map( + (item, i) => + item?.length > 0 && ( + + + + ) + )} + + )} + + ); + }} + /> + ); +} diff --git a/src/components/fields/MultiSelect/index.tsx b/src/components/fields/MultiSelect/index.tsx new file mode 100644 index 00000000..98dad8eb --- /dev/null +++ b/src/components/fields/MultiSelect/index.tsx @@ -0,0 +1,55 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; + +import MultiSelectIcon from "@src/assets/icons/MultiSelect"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import InlineCell from "./InlineCell"; +import NullEditor from "@src/components/Table/editors/NullEditor"; +import { filterOperators } from "./Filter"; +const PopoverCell = lazy( + () => + import("./PopoverCell" /* webpackChunkName: "PopoverCell-MultiSelect" */) +); +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-MultiSelect" */ + ) +); +const Settings = lazy( + () => + import( + "../SingleSelect/Settings" /* webpackChunkName: "Settings-SingleSelect" */ + ) +); + +export const config: IFieldConfig = { + type: FieldType.multiSelect, + name: "Multi Select", + group: "Select", + dataType: "string[]", + initialValue: [], + initializable: true, + icon: , + description: + "Multiple values from predefined options. Options are searchable and users can optionally input custom values.", + TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { + anchorOrigin: { horizontal: "left", vertical: "bottom" }, + transparent: true, + }), + TableEditor: NullEditor as any, + SideDrawerField, + settings: Settings, + csvImportParser: (v) => { + if (v.includes(",")) { + return v.split(",").map((i) => i.trim()); + } else if (v !== "") return [v]; + else return v; + }, + requireConfiguration: true, + filter: { + operators: filterOperators, + }, +}; +export default config; diff --git a/src/components/fields/MultiSelect/utils.ts b/src/components/fields/MultiSelect/utils.ts new file mode 100644 index 00000000..d2feec47 --- /dev/null +++ b/src/components/fields/MultiSelect/utils.ts @@ -0,0 +1,4 @@ +export const sanitiseValue = (value: any) => { + if (value === undefined || value === null || value === "") return []; + else return value as string[]; +}; diff --git a/src/components/fields/Number/BasicCell.tsx b/src/components/fields/Number/BasicCell.tsx new file mode 100644 index 00000000..71d6be89 --- /dev/null +++ b/src/components/fields/Number/BasicCell.tsx @@ -0,0 +1,5 @@ +import { IBasicCellProps } from "@src/components/fields/types"; + +export default function Number_({ value }: IBasicCellProps) { + return <>{`${value ?? ""}`}; +} diff --git a/src/components/fields/Number/Filter.tsx b/src/components/fields/Number/Filter.tsx new file mode 100644 index 00000000..07f44ac0 --- /dev/null +++ b/src/components/fields/Number/Filter.tsx @@ -0,0 +1,28 @@ +import { IFilterOperator } from "@src/components/fields/types"; + +export const filterOperators: IFilterOperator[] = [ + { + label: "=", + value: "==", + }, + { + label: "!=", + value: "!=", + }, + { + label: ">", + value: ">", + }, + { + label: ">=", + value: ">=", + }, + { + label: "<", + value: "<", + }, + { + label: "<=", + value: "<=", + }, +]; diff --git a/src/components/fields/Number/SideDrawerField.tsx b/src/components/fields/Number/SideDrawerField.tsx new file mode 100644 index 00000000..f65e1756 --- /dev/null +++ b/src/components/fields/Number/SideDrawerField.tsx @@ -0,0 +1,32 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { TextField } from "@mui/material"; + +export default function Number_({ + control, + column, + disabled, +}: ISideDrawerFieldProps) { + return ( + ( + onChange(Number(e.target.value))} + onBlur={onBlur} + value={value} + id={`sidedrawer-field-${column.key}`} + label="" + hiddenLabel + disabled={disabled} + type="number" + /> + )} + /> + ); +} diff --git a/src/components/fields/Number/index.tsx b/src/components/fields/Number/index.tsx new file mode 100644 index 00000000..2189d3cc --- /dev/null +++ b/src/components/fields/Number/index.tsx @@ -0,0 +1,40 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; + +import NumberIcon from "@src/assets/icons/Number"; +import BasicCell from "./BasicCell"; +import TextEditor from "@src/components/Table/editors/TextEditor"; +import { filterOperators } from "./Filter"; +import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Number" */) +); + +export const config: IFieldConfig = { + type: FieldType.number, + name: "Number", + group: "Numeric", + dataType: "number", + initialValue: 0, + initializable: true, + icon: , + description: "Numeric value.", + contextMenuActions: BasicContextMenuActions, + TableCell: withBasicCell(BasicCell), + TableEditor: TextEditor, + SideDrawerField, + filter: { + operators: filterOperators, + }, + csvImportParser: (v) => { + try { + const parsedValue = parseFloat(v); + return Number.isNaN(parsedValue) ? null : parsedValue; + } catch (e) { + return null; + } + }, +}; +export default config; diff --git a/src/components/fields/Percentage/BasicCell.tsx b/src/components/fields/Percentage/BasicCell.tsx new file mode 100644 index 00000000..05fcfac6 --- /dev/null +++ b/src/components/fields/Percentage/BasicCell.tsx @@ -0,0 +1,41 @@ +import { IBasicCellProps } from "@src/components/fields/types"; + +import { useTheme } from "@mui/material"; +import { resultColorsScale } from "@src/utils/color"; + +export default function Percentage({ value }: IBasicCellProps) { + const theme = useTheme(); + + if (typeof value === "number") + return ( + <> +
+
+ {Math.round(value * 100)}% +
+ + ); + + return null; +} diff --git a/src/components/fields/Percentage/SideDrawerField.tsx b/src/components/fields/Percentage/SideDrawerField.tsx new file mode 100644 index 00000000..01fbd778 --- /dev/null +++ b/src/components/fields/Percentage/SideDrawerField.tsx @@ -0,0 +1,54 @@ +import { Controller, useWatch } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { inputClasses, TextField } from "@mui/material"; +import { emphasize } from "@mui/material/styles"; +import { resultColorsScale } from "@src/utils/color"; + +export default function Percentage({ + control, + column, + disabled, +}: ISideDrawerFieldProps) { + const value: number | undefined = useWatch({ control, name: column.key }); + + return ( + ( + onChange(Number(e.target.value) / 100)} + onBlur={onBlur} + value={typeof value === "number" ? value * 100 : value} + id={`sidedrawer-field-${column.key}`} + label="" + hiddenLabel + disabled={disabled} + type="number" + InputProps={{ + endAdornment: "%", + sx: { + backgroundColor: + typeof value === "number" + ? resultColorsScale(value).toHex() + "!important" + : undefined, + color: + typeof value === "number" + ? emphasize(resultColorsScale(value).toHex(), 1) + + "!important" + : undefined, + + [`& ${inputClasses.underline}::after`]: { + borderColor: "text.primary", + }, + }, + }} + /> + )} + /> + ); +} diff --git a/src/components/fields/Percentage/index.tsx b/src/components/fields/Percentage/index.tsx new file mode 100644 index 00000000..bff8e03d --- /dev/null +++ b/src/components/fields/Percentage/index.tsx @@ -0,0 +1,42 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; + +import PercentageIcon from "@src/assets/icons/Percentage"; +import BasicCell from "./BasicCell"; +import TextEditor from "@src/components/Table/editors/TextEditor"; +import { filterOperators } from "@src/components/fields/Number/Filter"; +import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-Percentage" */ + ) +); + +export const config: IFieldConfig = { + type: FieldType.percentage, + name: "Percentage", + group: "Numeric", + dataType: "number", + initialValue: 0, + initializable: true, + icon: , + description: "Percentage stored as a number between 0 and 1.", + contextMenuActions: BasicContextMenuActions, + TableCell: withBasicCell(BasicCell), + TableEditor: TextEditor, + SideDrawerField, + filter: { + operators: filterOperators, + }, + csvImportParser: (v) => { + try { + const parsedValue = parseFloat(v); + return Number.isNaN(parsedValue) ? null : parsedValue; + } catch (e) { + return null; + } + }, +}; +export default config; diff --git a/src/components/fields/Phone/SideDrawerField.tsx b/src/components/fields/Phone/SideDrawerField.tsx new file mode 100644 index 00000000..fe2c8179 --- /dev/null +++ b/src/components/fields/Phone/SideDrawerField.tsx @@ -0,0 +1,38 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { TextField } from "@mui/material"; + +export default function Phone({ + control, + column, + disabled, +}: ISideDrawerFieldProps) { + return ( + { + return ( + + ); + }} + /> + ); +} diff --git a/src/components/fields/Phone/index.tsx b/src/components/fields/Phone/index.tsx new file mode 100644 index 00000000..96115973 --- /dev/null +++ b/src/components/fields/Phone/index.tsx @@ -0,0 +1,33 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; + +import PhoneIcon from "@mui/icons-material/PhoneOutlined"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellValue"; +import TextEditor from "@src/components/Table/editors/TextEditor"; +import { filterOperators } from "@src/components/fields/ShortText/Filter"; +import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; + +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Phone" */) +); + +export const config: IFieldConfig = { + type: FieldType.phone, + name: "Phone", + group: "Text", + dataType: "string", + initialValue: "", + initializable: true, + icon: , + description: "Phone number stored as text. Not validated.", + contextMenuActions: BasicContextMenuActions, + TableCell: withBasicCell(BasicCell), + TableEditor: TextEditor, + SideDrawerField, + filter: { + operators: filterOperators, + }, +}; +export default config; diff --git a/src/components/fields/Rating/Settings.tsx b/src/components/fields/Rating/Settings.tsx new file mode 100644 index 00000000..5ae075a7 --- /dev/null +++ b/src/components/fields/Rating/Settings.tsx @@ -0,0 +1,41 @@ +import { ISettingsProps } from "@src/components/fields/types"; + +import { Slider, InputLabel } from "@mui/material"; + +export default function Settings({ onChange, config }: ISettingsProps) { + return ( + <> + Maximum number of stars + `${v} max stars`} + aria-labelledby="max-slider" + valueLabelDisplay="auto" + onChange={(_, v) => { + onChange("max")(v); + }} + step={1} + marks + min={1} + max={15} + /> + + Slider precision + `${v} rating step size`} + aria-labelledby="precision-slider" + valueLabelDisplay="auto" + onChange={(_, v) => { + onChange("precision")(v); + }} + step={0.25} + marks + min={0.25} + max={1} + /> + + ); +} diff --git a/src/components/fields/Rating/SideDrawerField.tsx b/src/components/fields/Rating/SideDrawerField.tsx new file mode 100644 index 00000000..17acbce5 --- /dev/null +++ b/src/components/fields/Rating/SideDrawerField.tsx @@ -0,0 +1,50 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { Grid } from "@mui/material"; +import { Rating as MuiRating } from "@mui/material"; +import "@mui/lab"; +import StarBorderIcon from "@mui/icons-material/StarBorder"; + +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; + +export default function Rating({ + control, + column, + disabled, +}: ISideDrawerFieldProps) { + // Set max and precision from config + const { + max, + precision, + }: { + max: number; + precision: number; + } = { + max: 5, + precision: 1, + ...column.config, + }; + + return ( + ( + + onChange(newValue)} + emptyIcon={} + max={max} + precision={precision} + sx={{ ml: -0.5 }} + /> + + )} + /> + ); +} diff --git a/src/components/fields/Rating/TableCell.tsx b/src/components/fields/Rating/TableCell.tsx new file mode 100644 index 00000000..34da84cf --- /dev/null +++ b/src/components/fields/Rating/TableCell.tsx @@ -0,0 +1,39 @@ +import { IHeavyCellProps } from "@src/components/fields/types"; + +import MuiRating from "@mui/material/Rating"; +import StarBorderIcon from "@mui/icons-material/StarBorder"; + +export default function Rating({ + row, + column, + value, + onSubmit, + disabled, +}: IHeavyCellProps) { + // Set max and precision from config + const { + max, + precision, + }: { + max: number; + precision: number; + } = { + max: 5, + precision: 1, + ...column.config, + }; + + return ( + e.stopPropagation()} + disabled={disabled} + onChange={(_, newValue) => onSubmit(newValue)} + emptyIcon={} + max={max} + precision={precision} + sx={{ mx: -0.25 }} + /> + ); +} diff --git a/src/components/fields/Rating/index.tsx b/src/components/fields/Rating/index.tsx new file mode 100644 index 00000000..1492a4b4 --- /dev/null +++ b/src/components/fields/Rating/index.tsx @@ -0,0 +1,40 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import RatingIcon from "@mui/icons-material/StarBorder"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import NullEditor from "@src/components/Table/editors/NullEditor"; +import { filterOperators } from "@src/components/fields/Number/Filter"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-Rating" */) +); +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Rating" */) +); +const Settings = lazy( + () => import("./Settings" /* webpackChunkName: "Settings-Rating" */) +); + +export const config: IFieldConfig = { + type: FieldType.rating, + name: "Rating", + group: "Numeric", + dataType: "number", + initialValue: 0, + initializable: true, + icon: , + requireConfiguration: true, + description: + "Rating displayed as stars. Max stars is configurable, default: 5 stars.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: NullEditor as any, + settings: Settings, + SideDrawerField, + filter: { + operators: filterOperators, + }, +}; +export default config; diff --git a/src/components/fields/RichText/SideDrawerField.tsx b/src/components/fields/RichText/SideDrawerField.tsx new file mode 100644 index 00000000..2b586131 --- /dev/null +++ b/src/components/fields/RichText/SideDrawerField.tsx @@ -0,0 +1,24 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; +import RichTextEditor from "@src/components/RichTextEditor"; + +export default function RichText({ + control, + column, + disabled, +}: ISideDrawerFieldProps) { + return ( + ( + + )} + /> + ); +} diff --git a/src/components/fields/RichText/TableCell.tsx b/src/components/fields/RichText/TableCell.tsx new file mode 100644 index 00000000..354a9a76 --- /dev/null +++ b/src/components/fields/RichText/TableCell.tsx @@ -0,0 +1,98 @@ +import { IHeavyCellProps } from "@src/components/fields/types"; +import { useAtom } from "jotai"; + +import { + styled, + useTheme, + Tooltip, + TooltipProps, + tooltipClasses, + Fade, +} from "@mui/material"; +import RenderedHtml from "@src/components/RenderedHtml"; + +import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; +import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; + +type StylesProps = { width: number; rowHeight: number }; + +const StyledTooltip = styled( + ({ className, width, rowHeight, ...props }: TooltipProps & StylesProps) => ( + + ) +)(({ theme, width, rowHeight }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + margin: 0, + marginTop: `-${rowHeight - 1}px !important`, + padding: theme.spacing(3 / 8, 1.25), + + width: width - 1, + maxWidth: "none", + minHeight: rowHeight - 1, + overflowX: "hidden", + + background: theme.palette.background.paper, + borderRadius: 0, + boxShadow: `0 0 0 1px ${theme.palette.divider}, ${theme.shadows[4]}`, + color: theme.palette.text.primary, + + display: "flex", + alignItems: "center", + }, +})); + +export default function RichText({ column, value }: IHeavyCellProps) { + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + + const theme = useTheme(); + + if (!value) return null; + + const content = ( + + ); + + return ( + +
+ {content} +
+
+ ); +} diff --git a/src/components/fields/RichText/index.tsx b/src/components/fields/RichText/index.tsx new file mode 100644 index 00000000..890f1d68 --- /dev/null +++ b/src/components/fields/RichText/index.tsx @@ -0,0 +1,34 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import RichTextIcon from "@mui/icons-material/TextFormat"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-RichText" */) +); +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-RichText" */ + ) +); + +export const config: IFieldConfig = { + type: FieldType.richText, + name: "Rich Text", + group: "Text", + dataType: "string", + initialValue: "", + initializable: true, + icon: , + description: "HTML edited with a rich text editor.", + contextMenuActions: BasicContextMenuActions, + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: withSideDrawerEditor(TableCell), + SideDrawerField, +}; +export default config; diff --git a/src/components/fields/ShortText/Filter.tsx b/src/components/fields/ShortText/Filter.tsx new file mode 100644 index 00000000..1fbbbdf7 --- /dev/null +++ b/src/components/fields/ShortText/Filter.tsx @@ -0,0 +1,12 @@ +import { IFilterOperator } from "@src/components/fields/types"; + +export const filterOperators: IFilterOperator[] = [ + { + label: "equals", + value: "==", + }, + { + label: "not equals", + value: "!=", + }, +]; diff --git a/src/components/fields/ShortText/Settings.tsx b/src/components/fields/ShortText/Settings.tsx new file mode 100644 index 00000000..77cc58f9 --- /dev/null +++ b/src/components/fields/ShortText/Settings.tsx @@ -0,0 +1,31 @@ +import { ISettingsProps } from "@src/components/fields/types"; +import { TextField } from "@mui/material"; + +export default function Settings({ onChange, config }: ISettingsProps) { + return ( + <> + { + if (e.target.value === "0") onChange("maxLength")(null); + else onChange("maxLength")(e.target.value); + }} + /> + { + if (e.target.value === "") onChange("validationRegex")(null); + else onChange("validationRegex")(e.target.value); + }} + /> + + ); +} diff --git a/src/components/fields/ShortText/SideDrawerField.tsx b/src/components/fields/ShortText/SideDrawerField.tsx new file mode 100644 index 00000000..5b4e899c --- /dev/null +++ b/src/components/fields/ShortText/SideDrawerField.tsx @@ -0,0 +1,34 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { TextField } from "@mui/material"; + +export default function ShortText({ + control, + column, + disabled, +}: ISideDrawerFieldProps) { + return ( + { + return ( + + ); + }} + /> + ); +} diff --git a/src/components/fields/ShortText/index.tsx b/src/components/fields/ShortText/index.tsx new file mode 100644 index 00000000..aa288624 --- /dev/null +++ b/src/components/fields/ShortText/index.tsx @@ -0,0 +1,40 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; + +import ShortTextIcon from "@mui/icons-material/ShortText"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellValue"; +import TextEditor from "@src/components/Table/editors/TextEditor"; + +import { filterOperators } from "./Filter"; +import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; + +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-ShortText" */ + ) +); +const Settings = lazy( + () => import("./Settings" /* webpackChunkName: "Settings-ShortText" */) +); + +export const config: IFieldConfig = { + type: FieldType.shortText, + name: "Short Text", + group: "Text", + dataType: "string", + initialValue: "", + initializable: true, + icon: , + description: "Text displayed on a single line.", + contextMenuActions: BasicContextMenuActions, + TableCell: withBasicCell(BasicCell), + TableEditor: TextEditor, + SideDrawerField, + settings: Settings, + filter: { + operators: filterOperators, + }, +}; +export default config; diff --git a/src/components/fields/SingleSelect/InlineCell.tsx b/src/components/fields/SingleSelect/InlineCell.tsx new file mode 100644 index 00000000..8e3bab45 --- /dev/null +++ b/src/components/fields/SingleSelect/InlineCell.tsx @@ -0,0 +1,51 @@ +import { forwardRef } from "react"; +import { IPopoverInlineCellProps } from "@src/components/fields/types"; + +import { ButtonBase } from "@mui/material"; +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; + +import { sanitiseValue } from "./utils"; + +export const SingleSelect = forwardRef(function SingleSelect( + { value, showPopoverCell, disabled }: IPopoverInlineCellProps, + ref: React.Ref +) { + return ( + showPopoverCell(true)} + ref={ref} + disabled={disabled} + className="cell-collapse-padding" + style={{ + padding: "var(--cell-padding)", + paddingRight: 0, + height: "100%", + + font: "inherit", + color: "inherit !important", + letterSpacing: "inherit", + textAlign: "inherit", + justifyContent: "flex-start", + }} + > +
+ {sanitiseValue(value)} +
+ + {!disabled && ( + + )} +
+ ); +}); + +export default SingleSelect; diff --git a/src/components/fields/SingleSelect/PopoverCell.tsx b/src/components/fields/SingleSelect/PopoverCell.tsx new file mode 100644 index 00000000..4aafda6b --- /dev/null +++ b/src/components/fields/SingleSelect/PopoverCell.tsx @@ -0,0 +1,41 @@ +import { IPopoverCellProps } from "@src/components/fields/types"; + +import MultiSelect_ from "@rowy/multiselect"; + +import { sanitiseValue } from "./utils"; + +export default function SingleSelect({ + value, + onSubmit, + column, + parentRef, + showPopoverCell, + disabled, +}: IPopoverCellProps) { + const config = column.config ?? {}; + + return ( + showPopoverCell(false)} + /> + ); +} diff --git a/src/components/fields/SingleSelect/Settings.tsx b/src/components/fields/SingleSelect/Settings.tsx new file mode 100644 index 00000000..69649a26 --- /dev/null +++ b/src/components/fields/SingleSelect/Settings.tsx @@ -0,0 +1,122 @@ +import { useState, useRef } from "react"; +import { ISettingsProps } from "@src/components/fields/types"; + +import { + InputLabel, + TextField, + Grid, + IconButton, + Typography, + Divider, + FormControlLabel, + Checkbox, + FormHelperText, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/AddCircle"; +import RemoveIcon from "@mui/icons-material/CancelRounded"; + +export default function Settings({ onChange, config }: ISettingsProps) { + const listEndRef: any = useRef(null); + const options = config.options ?? []; + const [newOption, setNewOption] = useState(""); + const handleAdd = () => { + if (newOption.trim() !== "") { + onChange("options")([...options, newOption.trim()]); + setNewOption(""); + listEndRef.current.scrollIntoView({ behavior: "smooth", block: "end" }); + } + }; + + return ( +
+ Options +
+ {options?.map((option: string) => ( + <> + + + {option} + + + + onChange("options")( + options.filter((o: string) => o !== option) + ) + } + > + {} + + + + + + ))} +
+
+ + + + { + handleAdd(); + }} + > + {} + + + + { + setNewOption(e.target.value); + }} + onKeyPress={(e: any) => { + if (e.key === "Enter") { + handleAdd(); + } + }} + helperText=" " + /> + + + + onChange("freeText")(e.target.checked)} + /> + } + label={ + <> + Users can add custom options + + Custom options will only appear in the row it was added to. They + will not appear in the list of options above. + + + } + style={{ marginLeft: -10 }} + /> +
+ ); +} diff --git a/src/components/fields/SingleSelect/SideDrawerField.tsx b/src/components/fields/SingleSelect/SideDrawerField.tsx new file mode 100644 index 00000000..eed5fa81 --- /dev/null +++ b/src/components/fields/SingleSelect/SideDrawerField.tsx @@ -0,0 +1,38 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import MultiSelect from "@rowy/multiselect"; +import { sanitiseValue } from "./utils"; + +export default function SingleSelect({ + column, + control, + disabled, +}: ISideDrawerFieldProps) { + const config = column.config ?? {}; + + return ( + ( + <> + + + )} + /> + ); +} diff --git a/src/components/fields/SingleSelect/index.tsx b/src/components/fields/SingleSelect/index.tsx new file mode 100644 index 00000000..307438c4 --- /dev/null +++ b/src/components/fields/SingleSelect/index.tsx @@ -0,0 +1,45 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; + +import SingleSelectIcon from "@src/assets/icons/SingleSelect"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import InlineCell from "./InlineCell"; +import NullEditor from "@src/components/Table/editors/NullEditor"; +import { filterOperators } from "@src/components/fields/ShortText/Filter"; + +const PopoverCell = lazy( + () => + import("./PopoverCell" /* webpackChunkName: "PopoverCell-SingleSelect" */) +); +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-SingleSelect" */ + ) +); +const Settings = lazy( + () => import("./Settings" /* webpackChunkName: "Settings-SingleSelect" */) +); + +export const config: IFieldConfig = { + type: FieldType.singleSelect, + name: "Single Select", + group: "Select", + dataType: "string | null", + initialValue: null, + initializable: true, + icon: , + description: + "Single value from predefined options. Options are searchable and users can optionally input custom values.", + TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { + anchorOrigin: { horizontal: "left", vertical: "bottom" }, + transparent: true, + }), + TableEditor: NullEditor as any, + SideDrawerField, + settings: Settings, + filter: { operators: filterOperators }, + requireConfiguration: true, +}; +export default config; diff --git a/src/components/fields/SingleSelect/utils.ts b/src/components/fields/SingleSelect/utils.ts new file mode 100644 index 00000000..3070d669 --- /dev/null +++ b/src/components/fields/SingleSelect/utils.ts @@ -0,0 +1,5 @@ +export const sanitiseValue = (value: any) => { + if (value === undefined || value === null || value === "") return null; + else if (Array.isArray(value)) return value[0] as string; + else return value as string; +}; diff --git a/src/components/fields/Slider/Settings.tsx b/src/components/fields/Slider/Settings.tsx new file mode 100644 index 00000000..c4bb268e --- /dev/null +++ b/src/components/fields/Slider/Settings.tsx @@ -0,0 +1,52 @@ +import { ISettingsProps } from "@src/components/fields/types"; +import { TextField, FormControlLabel, Switch } from "@mui/material"; + +export default function Settings({ onChange, config }: ISettingsProps) { + return ( + <> + onChange("min")(parseFloat(e.target.value))} + value={config["min"]} + id={`settings-field-min`} + label="Minimum value" + type="number" + /> + + onChange("max")(parseFloat(e.target.value))} + value={config["max"]} + id={`settings-field-max`} + label="Maximum value" + type="number" + /> + + onChange("step")(parseFloat(e.target.value))} + value={config["step"]} + id={`settings-field-step`} + label="Step value" + type="number" + /> + + onChange("marks")(!Boolean(config.marks))} + name="marks" + /> + } + label="Show slider steps" + /> + + ); +} diff --git a/src/components/fields/Slider/SideDrawerField.tsx b/src/components/fields/Slider/SideDrawerField.tsx new file mode 100644 index 00000000..246c3dc6 --- /dev/null +++ b/src/components/fields/Slider/SideDrawerField.tsx @@ -0,0 +1,80 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { Slider as MuiSlider, Stack, Typography } from "@mui/material"; +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; + +export default function Slider({ + control, + column, + disabled, +}: ISideDrawerFieldProps) { + const config: { + max: number; + min: number; + minLabel?: string; + maxLabel?: string; + step: number; + unit: string; + marks?: boolean; + } = { + max: 10, + step: 1, + units: "", + min: 0, + ...(column as any).config, + }; + const { max, marks, min, unit, minLabel, maxLabel, step } = config; + + return ( + { + const handleChange = (_: any, value: number | number[]) => { + onChange(value); + onBlur(); + }; + + const getAriaValueText = (value: number) => + `${value}${unit ? " " + unit : ""}`; + + const getValueLabelFormat = (value: number) => + `${value}${unit ? " " + unit : ""}`; + + return ( + + + {minLabel ?? `${min}${unit ? " " + unit : ""}`} + + + + + + {maxLabel ?? `${max}${unit ? " " + unit : ""}`} + + + ); + }} + /> + ); +} diff --git a/src/components/fields/Slider/TableCell.tsx b/src/components/fields/Slider/TableCell.tsx new file mode 100644 index 00000000..80699449 --- /dev/null +++ b/src/components/fields/Slider/TableCell.tsx @@ -0,0 +1,58 @@ +import { IHeavyCellProps } from "@src/components/fields/types"; + +import { Grid, Box } from "@mui/material"; + +import { resultColorsScale } from "@src/utils/color"; + +export default function Slider({ column, value }: IHeavyCellProps) { + const { + max, + min, + unit, + }: { + max: number; + min: number; + unit?: string; + } = { + max: 10, + min: 0, + ...(column as any).config, + }; + + const progress = + value < min || typeof value !== "number" + ? 0 + : ((value - min) / (max - min)) * 100; + + return ( + + + {value ?? 0}/{max} {unit} + + + + + + + + + ); +} diff --git a/src/components/fields/Slider/index.tsx b/src/components/fields/Slider/index.tsx new file mode 100644 index 00000000..033b8f1c --- /dev/null +++ b/src/components/fields/Slider/index.tsx @@ -0,0 +1,39 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import SliderIcon from "@src/assets/icons/Slider"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import { filterOperators } from "@src/components/fields/Number/Filter"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-Slider" */) +); +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Slider" */) +); +const Settings = lazy( + () => import("./Settings" /* webpackChunkName: "Settings-Slider" */) +); + +export const config: IFieldConfig = { + type: FieldType.slider, + name: "Slider", + group: "Numeric", + dataType: "number", + initialValue: 0, + initializable: true, + icon: , + requireConfiguration: true, + description: "Numeric value edited with a Slider. Range is configurable.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: withSideDrawerEditor(TableCell), + settings: Settings, + filter: { + operators: filterOperators, + }, + SideDrawerField, +}; +export default config; diff --git a/src/components/fields/UpdatedAt/SideDrawerField.tsx b/src/components/fields/UpdatedAt/SideDrawerField.tsx new file mode 100644 index 00000000..b098e26c --- /dev/null +++ b/src/components/fields/UpdatedAt/SideDrawerField.tsx @@ -0,0 +1,31 @@ +import { useWatch } from "react-hook-form"; +import { useAtom } from "jotai"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { Box } from "@mui/material"; +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; + +import { format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; + +export default function UpdatedAt({ control, column }: ISideDrawerFieldProps) { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const value = useWatch({ + control, + name: tableSettings.auditFieldUpdatedBy || "_updatedBy", + }); + + if (!value || !value.timestamp) return ; + + const dateLabel = format( + value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, + column.config?.format || DATE_TIME_FORMAT + ); + + return ( + + {dateLabel} + + ); +} diff --git a/src/components/fields/UpdatedAt/TableCell.tsx b/src/components/fields/UpdatedAt/TableCell.tsx new file mode 100644 index 00000000..71ae28c8 --- /dev/null +++ b/src/components/fields/UpdatedAt/TableCell.tsx @@ -0,0 +1,21 @@ +import { useAtom } from "jotai"; +import { IHeavyCellProps } from "@src/components/fields/types"; + +import { format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; + +export default function UpdatedBy({ row, column }: IHeavyCellProps) { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const value = row[tableSettings.auditFieldUpdatedBy || "_updatedBy"]; + + if (!value || !value.timestamp) return null; + const dateLabel = format( + value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, + column.config?.format || DATE_TIME_FORMAT + ); + + return ( + {dateLabel} + ); +} diff --git a/src/components/fields/UpdatedAt/index.tsx b/src/components/fields/UpdatedAt/index.tsx new file mode 100644 index 00000000..d38f1a45 --- /dev/null +++ b/src/components/fields/UpdatedAt/index.tsx @@ -0,0 +1,37 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import UpdatedAtIcon from "@src/assets/icons/UpdatedAt"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-UpdatedAt" */) +); +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-UpdatedAt" */ + ) +); +const Settings = lazy( + () => + import("../CreatedBy/Settings" /* webpackChunkName: "Settings-CreatedBy" */) +); + +export const config: IFieldConfig = { + type: FieldType.updatedAt, + name: "Updated At", + group: "Auditing", + dataType: "firebase.firestore.Timestamp", + initialValue: null, + icon: , + description: + "Displays the timestamp of the last update to the row. Read-only.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: withSideDrawerEditor(TableCell), + SideDrawerField, + settings: Settings, +}; +export default config; diff --git a/src/components/fields/UpdatedBy/SideDrawerField.tsx b/src/components/fields/UpdatedBy/SideDrawerField.tsx new file mode 100644 index 00000000..cec39b06 --- /dev/null +++ b/src/components/fields/UpdatedBy/SideDrawerField.tsx @@ -0,0 +1,63 @@ +import { useWatch } from "react-hook-form"; +import { useAtom } from "jotai"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { Box, Stack, Typography, Avatar } from "@mui/material"; +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; + +import { format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; + +export default function UpdatedBy({ control, column }: ISideDrawerFieldProps) { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const value = useWatch({ + control, + name: tableSettings.auditFieldUpdatedBy || "_updatedBy", + }); + + if (!value || !value.displayName || !value.timestamp) + return ; + + const dateLabel = format( + value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, + column.config?.format || DATE_TIME_FORMAT + ); + + return ( + + + {value.displayName[0]} + + + + {value.displayName} ({value.email}) + + Updated + {value.updatedField && ( + <> + {" "} + field {value.updatedField} + + )}{" "} + at {dateLabel} + + + + ); +} diff --git a/src/components/fields/UpdatedBy/TableCell.tsx b/src/components/fields/UpdatedBy/TableCell.tsx new file mode 100644 index 00000000..eb2e772a --- /dev/null +++ b/src/components/fields/UpdatedBy/TableCell.tsx @@ -0,0 +1,48 @@ +import { useAtom } from "jotai"; +import { IHeavyCellProps } from "@src/components/fields/types"; + +import { Tooltip, Stack, Avatar } from "@mui/material"; + +import { format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; + +export default function UpdatedBy({ row, column }: IHeavyCellProps) { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const value = row[tableSettings.auditFieldUpdatedBy || "_updatedBy"]; + + if (!value || !value.displayName || !value.timestamp) return null; + const dateLabel = format( + value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, + column.config?.format || DATE_TIME_FORMAT + ); + + return ( + + Updated + {value.updatedField && ( + <> + {" "} + field {value.updatedField} + + )} +
+ at {dateLabel} + + } + > + + + {value.displayName[0]} + + {value.displayName} + +
+ ); +} diff --git a/src/components/fields/UpdatedBy/index.tsx b/src/components/fields/UpdatedBy/index.tsx new file mode 100644 index 00000000..805b1fee --- /dev/null +++ b/src/components/fields/UpdatedBy/index.tsx @@ -0,0 +1,38 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import UpdatedByIcon from "@src/assets/icons/UpdatedBy"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-UpdatedBy" */) +); +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-UpdatedBy" */ + ) +); +const Settings = lazy( + () => + import("../CreatedBy/Settings" /* webpackChunkName: "Settings-CreatedBy" */) +); + +export const config: IFieldConfig = { + type: FieldType.updatedBy, + name: "Updated By", + group: "Auditing", + dataType: + "{ displayName: string; email: string; emailVerified: boolean; isAnonymous: boolean; photoURL: string; uid: string; timestamp: firebase.firestore.Timestamp; updatedField?: string; }", + initialValue: null, + icon: , + description: + "Displays the user that last updated the row, timestamp, and updated field key. Read-only.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: withSideDrawerEditor(TableCell), + SideDrawerField, + settings: Settings, +}; +export default config; diff --git a/src/components/fields/Url/SideDrawerField.tsx b/src/components/fields/Url/SideDrawerField.tsx new file mode 100644 index 00000000..679a8d6f --- /dev/null +++ b/src/components/fields/Url/SideDrawerField.tsx @@ -0,0 +1,53 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { Stack, TextField, IconButton } from "@mui/material"; +import LaunchIcon from "@mui/icons-material/Launch"; + +export default function Url({ + control, + column, + disabled, +}: ISideDrawerFieldProps) { + return ( + { + return ( + + + + + + + + ); + }} + /> + ); +} diff --git a/src/components/fields/Url/TableCell.tsx b/src/components/fields/Url/TableCell.tsx new file mode 100644 index 00000000..02b66b5d --- /dev/null +++ b/src/components/fields/Url/TableCell.tsx @@ -0,0 +1,33 @@ +import { IBasicCellProps } from "@src/components/fields/types"; + +import { Stack, IconButton } from "@mui/material"; +import LaunchIcon from "@mui/icons-material/Launch"; + +export default function Url({ value }: IBasicCellProps) { + if (!value || typeof value !== "string") return null; + + const href = value.includes("http") ? value : `https://${value}`; + + return ( + +
{value}
+ + + + +
+ ); +} diff --git a/src/components/fields/Url/index.tsx b/src/components/fields/Url/index.tsx new file mode 100644 index 00000000..95376b06 --- /dev/null +++ b/src/components/fields/Url/index.tsx @@ -0,0 +1,33 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; + +import UrlIcon from "@mui/icons-material/Link"; +import TableCell from "./TableCell"; +import TextEditor from "@src/components/Table/editors/TextEditor"; +import { filterOperators } from "@src/components/fields/ShortText/Filter"; +import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; + +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Url" */) +); + +export const config: IFieldConfig = { + type: FieldType.url, + name: "URL", + group: "Text", + dataType: "string", + initialValue: "", + initializable: true, + icon: , + description: "Web address. Not validated.", + contextMenuActions: BasicContextMenuActions, + TableCell: withBasicCell(TableCell), + TableEditor: TextEditor, + SideDrawerField, + filter: { + operators: filterOperators, + }, +}; +export default config; diff --git a/src/components/fields/User/SideDrawerField.tsx b/src/components/fields/User/SideDrawerField.tsx new file mode 100644 index 00000000..ef0528fa --- /dev/null +++ b/src/components/fields/User/SideDrawerField.tsx @@ -0,0 +1,57 @@ +import { Controller } from "react-hook-form"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; + +import { Box, Stack, Typography, Avatar } from "@mui/material"; +import { fieldSx } from "@src/components/SideDrawer/Form/utils"; + +import { format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; + +export default function User({ control, column }: ISideDrawerFieldProps) { + return ( + { + if (!value || !value.displayName || !value.timestamp) + return ; + + const dateLabel = value.timestamp + ? format( + value.timestamp.toDate + ? value.timestamp.toDate() + : value.timestamp, + column.config?.format || DATE_TIME_FORMAT + ) + : null; + + return ( + + + + + {value.displayName} ({value.email}) + {dateLabel && ( + + {dateLabel} + + )} + + + ); + }} + /> + ); +} diff --git a/src/components/fields/User/TableCell.tsx b/src/components/fields/User/TableCell.tsx new file mode 100644 index 00000000..e1761826 --- /dev/null +++ b/src/components/fields/User/TableCell.tsx @@ -0,0 +1,30 @@ +import { IHeavyCellProps } from "@src/components/fields/types"; + +import { Tooltip, Stack, Avatar } from "@mui/material"; + +import { format } from "date-fns"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; + +export default function User({ value, column }: IHeavyCellProps) { + if (!value || !value.displayName) return null; + + const chip = ( + + + {value.displayName} + + ); + + if (!value.timestamp) return chip; + + const dateLabel = format( + value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, + column.config?.format || DATE_TIME_FORMAT + ); + + return {chip}; +} diff --git a/src/components/fields/User/index.tsx b/src/components/fields/User/index.tsx new file mode 100644 index 00000000..70ec0a83 --- /dev/null +++ b/src/components/fields/User/index.tsx @@ -0,0 +1,35 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; + +import UserIcon from "@mui/icons-material/PersonOutlined"; +import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; +import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; + +const TableCell = lazy( + () => import("./TableCell" /* webpackChunkName: "TableCell-User" */) +); +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-User" */) +); +const Settings = lazy( + () => + import("../CreatedBy/Settings" /* webpackChunkName: "Settings-CreatedBy" */) +); + +export const config: IFieldConfig = { + type: FieldType.user, + name: "User", + group: "Metadata", + dataType: + "{ displayName: string; email: string; emailVerified: boolean; isAnonymous: boolean; photoURL: string; uid: string; timestamp?: firebase.firestore.Timestamp; }", + initialValue: null, + icon: , + description: "User information and optionally, timestamp. Read-only.", + TableCell: withHeavyCell(BasicCell, TableCell), + TableEditor: withSideDrawerEditor(TableCell), + SideDrawerField, + settings: Settings, +}; +export default config; diff --git a/src/components/fields/_BasicCell/BasicCellContextMenuActions.tsx b/src/components/fields/_BasicCell/BasicCellContextMenuActions.tsx new file mode 100644 index 00000000..0a95a69f --- /dev/null +++ b/src/components/fields/_BasicCell/BasicCellContextMenuActions.tsx @@ -0,0 +1,106 @@ +import { useAtom, useSetAtom } from "jotai"; +import { get } from "lodash-es"; + +import Cut from "@mui/icons-material/ContentCut"; +import CopyCells from "@src/assets/icons/Copy"; +import Paste from "@mui/icons-material/ContentPaste"; + +import { + tableScope, + tableColumnsOrderedAtom, + tableRowsAtom, + updateFieldAtom, +} from "@src/atoms/tableScope"; +import { useSnackbar } from "notistack"; +// import { SelectedCell } from "@src/atoms/ContextMenu"; +import { getFieldProp, getColumnType } from "@src/components/fields"; + +export interface IContextMenuActions { + label: string; + icon: React.ReactNode; + onClick: () => void; +} + +export default function BasicContextMenuActions( + selectedCell: any, + reset: () => void | Promise +): IContextMenuActions[] { + const { enqueueSnackbar } = useSnackbar(); + + // TODO: Remove these useAtom calls that cause re-render + const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); + const [tableRows] = useAtom(tableRowsAtom, tableScope); + const updateField = useSetAtom(updateFieldAtom, tableScope); + + const selectedCol = tableColumnsOrdered[selectedCell?.colIndex]; + if (!selectedCol) return []; + + const selectedRow = tableRows[selectedCell.rowIndex]; + const cellValue = get(selectedRow, selectedCol.key); + + const handleClose = async () => await reset?.(); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(cellValue); + enqueueSnackbar("Copied"); + } catch (error) { + enqueueSnackbar(`Failed to copy:${error}`, { variant: "error" }); + } + handleClose(); + }; + + const handleCut = async () => { + try { + await navigator.clipboard.writeText(cellValue); + if (typeof cellValue !== "undefined") + updateField({ + path: selectedRow._rowy_ref.path, + fieldName: selectedCol.fieldName, + value: undefined, + deleteField: true, + }); + } catch (error) { + enqueueSnackbar(`Failed to cut: ${error}`, { variant: "error" }); + } + handleClose(); + }; + + const handlePaste = async () => { + try { + if (!selectedCol) return; + const text = await navigator.clipboard.readText(); + const cellDataType = getFieldProp("dataType", getColumnType(selectedCol)); + let parsed; + switch (cellDataType) { + case "number": + parsed = Number(text); + if (isNaN(parsed)) throw new Error(`${text} is not a number`); + break; + case "string": + parsed = text; + break; + default: + parsed = JSON.parse(text); + break; + } + updateField({ + path: selectedRow._rowy_ref.path, + fieldName: selectedCol.fieldName, + value: parsed, + }); + } catch (error) { + enqueueSnackbar(`Failed to paste: ${error}`, { variant: "error" }); + } + + handleClose(); + }; + + const contextMenuActions = [ + { label: "Cut", icon: , onClick: handleCut }, + { label: "Copy", icon: , onClick: handleCopy }, + { label: "Paste", icon: , onClick: handlePaste }, + ]; + + return contextMenuActions; +} diff --git a/src/components/fields/_BasicCell/BasicCellName.tsx b/src/components/fields/_BasicCell/BasicCellName.tsx new file mode 100644 index 00000000..5d93cd7b --- /dev/null +++ b/src/components/fields/_BasicCell/BasicCellName.tsx @@ -0,0 +1,5 @@ +import { IBasicCellProps } from "@src/components/fields/types"; + +export default function BasicCellName({ name }: IBasicCellProps) { + return <>{name}; +} diff --git a/src/components/fields/_BasicCell/BasicCellNull.tsx b/src/components/fields/_BasicCell/BasicCellNull.tsx new file mode 100644 index 00000000..43d91ccb --- /dev/null +++ b/src/components/fields/_BasicCell/BasicCellNull.tsx @@ -0,0 +1,3 @@ +export default function BasicCellNull() { + return null; +} diff --git a/src/components/fields/_BasicCell/BasicCellValue.tsx b/src/components/fields/_BasicCell/BasicCellValue.tsx new file mode 100644 index 00000000..8d8b0445 --- /dev/null +++ b/src/components/fields/_BasicCell/BasicCellValue.tsx @@ -0,0 +1,6 @@ +import { IBasicCellProps } from "@src/components/fields/types"; + +export default function BasicCellValue({ value }: IBasicCellProps) { + if (typeof value !== "string") return null; + return <>{value}; +} diff --git a/src/components/fields/_withTableCell/withBasicCell.tsx b/src/components/fields/_withTableCell/withBasicCell.tsx new file mode 100644 index 00000000..09d0bcb9 --- /dev/null +++ b/src/components/fields/_withTableCell/withBasicCell.tsx @@ -0,0 +1,41 @@ +import { get } from "lodash-es"; +import { FormatterProps } from "react-data-grid"; +import { ErrorBoundary } from "react-error-boundary"; +import { IBasicCellProps } from "@src/components/fields/types"; + +import { InlineErrorFallback } from "@src/components/ErrorFallback"; +import CellValidation from "@src/components/Table/CellValidation"; +import { FieldType } from "@src/constants/fields"; +import { TableRow } from "@src/types/table"; + +/** + * HOC to wrap around table cell components. + * Renders read-only BasicCell only. + * @param BasicCellComponent - The light cell component to display at all times + */ +export default function withBasicCell( + BasicCellComponent: React.ComponentType +) { + return function BasicCell(props: FormatterProps) { + const { name, key } = props.column; + const value = get(props.row, key); + + const { validationRegex, required } = (props.column as any).config; + + return ( + + + + + + ); + }; +} diff --git a/src/components/fields/_withTableCell/withHeavyCell.tsx b/src/components/fields/_withTableCell/withHeavyCell.tsx new file mode 100644 index 00000000..29eaf370 --- /dev/null +++ b/src/components/fields/_withTableCell/withHeavyCell.tsx @@ -0,0 +1,105 @@ +import { Suspense, useState, useEffect } from "react"; +import { useSetAtom } from "jotai"; +import { get } from "lodash-es"; +import { FormatterProps } from "react-data-grid"; +import { ErrorBoundary } from "react-error-boundary"; +import { IBasicCellProps, IHeavyCellProps } from "@src/components/fields/types"; + +import { InlineErrorFallback } from "@src/components/ErrorFallback"; +import CellValidation from "@src/components/Table/CellValidation"; + +import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; +import { TableRow } from "@src/types/table"; + +/** + * HOC to wrap table cell components. + * Renders read-only BasicCell while scrolling for better scroll performance. + * @param BasicCellComponent - The lighter cell component to display while scrolling + * @param HeavyCellComponent - The read/write cell component to display + * @param readOnly - Prevent the component from updating the cell value + */ +export default function withHeavyCell( + BasicCellComponent: React.ComponentType, + HeavyCellComponent: React.ComponentType, + readOnly: boolean = false +) { + return function HeavyCell(props: FormatterProps) { + const updateField = useSetAtom(updateFieldAtom, tableScope); + + const { validationRegex, required } = (props.column as any).config; + + // Initially display BasicCell to improve scroll performance + const [displayedComponent, setDisplayedComponent] = useState< + "basic" | "heavy" + >("basic"); + // Then switch to HeavyCell once completed + useEffect(() => { + setTimeout(() => { + setDisplayedComponent("heavy"); + }); + }, []); + + // TODO: Investigate if this still needs to be a state + const value = get(props.row, props.column.key); + const [localValue, setLocalValue] = useState(value); + useEffect(() => { + setLocalValue(value); + }, [value]); + + // Declare basicCell here so props can be reused by HeavyCellComponent + const basicCellProps = { + value: localValue, + name: props.column.name as string, + type: (props.column as any).type as FieldType, + }; + const basicCell = ; + + if (displayedComponent === "basic") + return ( + + + {basicCell} + + + ); + + const handleSubmit = (value: any) => { + if (readOnly) return; + updateField({ + path: props.row._rowy_ref.path, + fieldName: props.column.key, + value, + }); + setLocalValue(value); + }; + + if (displayedComponent === "heavy") + return ( + + + + + + + + ); + + // Should not reach this line + return null; + }; +} diff --git a/src/components/fields/_withTableCell/withPopoverCell.tsx b/src/components/fields/_withTableCell/withPopoverCell.tsx new file mode 100644 index 00000000..3a5c6185 --- /dev/null +++ b/src/components/fields/_withTableCell/withPopoverCell.tsx @@ -0,0 +1,184 @@ +import { Suspense, useState, useEffect, useRef } from "react"; +import { useSetAtom } from "jotai"; +import { find, get } from "lodash-es"; +import { FormatterProps } from "react-data-grid"; +import { + IBasicCellProps, + IPopoverInlineCellProps, + IPopoverCellProps, +} from "@src/components/fields/types"; +import { ErrorBoundary } from "react-error-boundary"; + +import { Popover, PopoverProps } from "@mui/material"; + +import { InlineErrorFallback } from "@src/components/ErrorFallback"; +import CellValidation from "@src/components/Table/CellValidation"; + +import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; +import { FieldType } from "@src/constants/fields"; + +export interface IPopoverCellOptions extends Partial { + transparent?: boolean; + readOnly?: boolean; +} + +/** + * HOC to wrap around table cell formatters. + * Renders read-only BasicCell while scrolling for better scroll performance. + * When the user clicks the heavier inline cell, it displays PopoverCell. + * @param BasicCellComponent - The lighter cell component to display while scrolling + * @param InlineCellComponent - The heavier cell component to display inline + * @param PopoverCellComponent - The heavy read/write cell component to display in Popover + * @param options - {@link IPopoverCellOptions} + */ +export default function withPopoverCell( + BasicCellComponent: React.ComponentType, + InlineCellComponent: React.ForwardRefExoticComponent< + IPopoverInlineCellProps & React.RefAttributes + >, + PopoverCellComponent: React.ComponentType, + options?: IPopoverCellOptions +) { + return function PopoverCell(props: FormatterProps) { + const { transparent, ...popoverProps } = options ?? {}; + + const updateField = useSetAtom(updateFieldAtom, tableScope); + + const { validationRegex, required } = (props.column as any).config; + + // Initially display BasicCell to improve scroll performance + const [displayedComponent, setDisplayedComponent] = useState< + "basic" | "inline" | "popover" + >("basic"); + // Then switch to heavier InlineCell once completed + useEffect(() => { + setTimeout(() => { + setDisplayedComponent("inline"); + }); + }, []); + + // Store Popover open state here so we can add delay for close transition + const [popoverOpen, setPopoverOpen] = useState(false); + + // Store ref to rendered InlineCell here to get positioning for PopoverCell + const inlineCellRef = useRef(null); + + // TODO: Investigate if this still needs to be a state + const value = get(props.row, props.column.key); + const [localValue, setLocalValue] = useState(value); + useEffect(() => { + setLocalValue(value); + }, [value]); + + // Declare basicCell here so props can be reused by HeavyCellComponent + const basicCellProps = { + value: localValue, + name: props.column.name as string, + type: (props.column as any).type as FieldType, + }; + + if (displayedComponent === "basic") + return ( + + + + + + ); + + // This is where we update the documents + const handleSubmit = (value: any) => { + if (options?.readOnly) return; + updateField({ + path: props.row._rowy_ref.path, + fieldName: props.column.key, + value, + deleteField: value === undefined, + }); + setLocalValue(value); + }; + const showPopoverCell: any = (popover: boolean) => { + if (popover) { + setPopoverOpen(true); + setDisplayedComponent("popover"); + } else { + setPopoverOpen(false); + setTimeout(() => setDisplayedComponent("inline"), 300); + } + }; + + // Declare inlineCell and props here so it can be reused later + const commonCellProps = { + ...props, + ...basicCellProps, + column: props.column, + onSubmit: handleSubmit, + disabled: props.column.editable === false, + docRef: props.row.ref, + showPopoverCell, + ref: inlineCellRef, + }; + const inlineCell = ( + + ); + + if (displayedComponent === "inline") + return ( + + + {inlineCell} + + + ); + + const parentRef = inlineCellRef.current?.parentElement; + + if (displayedComponent === "popover") + return ( + + + {inlineCell} + + + + showPopoverCell(false)} + {...popoverProps} + sx={ + transparent + ? { + "& .MuiPopover-paper": { backgroundColor: "transparent" }, + } + : {} + } + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + + + + ); + + // Should not reach this line + return null; + }; +} diff --git a/src/components/fields/index.tsx b/src/components/fields/index.tsx index ab31d466..cba52dbb 100644 --- a/src/components/fields/index.tsx +++ b/src/components/fields/index.tsx @@ -3,26 +3,26 @@ import { find, get } from "lodash-es"; import { FieldType } from "@src/constants/fields"; import { IFieldConfig } from "./types"; -// // Import field configs -// import ShortText from "./ShortText"; -// import LongText from "./LongText"; -// import RichText from "./RichText"; -// import Email from "./Email"; -// import Phone from "./Phone"; -// import Url from "./Url"; -// import Number_ from "./Number"; -// import Checkbox from "./Checkbox"; -// import Percentage from "./Percentage"; -// import Rating from "./Rating"; -// import Slider from "./Slider"; -// import Color from "./Color"; -// import Date_ from "./Date"; -// import DateTime from "./DateTime"; -// import Duration from "./Duration"; -// import Image_ from "./Image"; -// import File_ from "./File"; -// import SingleSelect from "./SingleSelect"; -// import MultiSelect from "./MultiSelect"; +// Import field configs +import ShortText from "./ShortText"; +import LongText from "./LongText"; +import RichText from "./RichText"; +import Email from "./Email"; +import Phone from "./Phone"; +import Url from "./Url"; +import SingleSelect from "./SingleSelect"; +import MultiSelect from "./MultiSelect"; +import Number_ from "./Number"; +import Checkbox from "./Checkbox"; +import Percentage from "./Percentage"; +import Rating from "./Rating"; +import Slider from "./Slider"; +import Color from "./Color"; +import Date_ from "./Date"; +import DateTime from "./DateTime"; +import Duration from "./Duration"; +import Image_ from "./Image"; +import File_ from "./File"; // import SubTable from "./SubTable"; // import ConnectTable from "./ConnectTable"; // import ConnectService from "./ConnectService"; @@ -31,63 +31,64 @@ import { IFieldConfig } from "./types"; // import Action from "./Action"; // import Derivative from "./Derivative"; // // import Aggregate from "./Aggregate"; -// import CreatedBy from "./CreatedBy"; -// import UpdatedBy from "./UpdatedBy"; -// import CreatedAt from "./CreatedAt"; -// import UpdatedAt from "./UpdatedAt"; -// import User from "./User"; -// import Id from "./Id"; // import Status from "./Status"; +import CreatedBy from "./CreatedBy"; +import UpdatedBy from "./UpdatedBy"; +import CreatedAt from "./CreatedAt"; +import UpdatedAt from "./UpdatedAt"; +import User from "./User"; +import Id from "./Id"; +import { ColumnConfig } from "@src/types/table"; + // import Connector from "./Connector"; -// import { TableColumn } from "../Table"; // Export field configs in order for FieldsDropdown export const FIELDS: IFieldConfig[] = [ - // // TEXT - // ShortText, - // LongText, - // RichText, - // Email, - // Phone, - // Url, - // // SELECT - // SingleSelect, - // MultiSelect, - // // NUMERIC - // Number_, - // Checkbox, - // Percentage, - // Rating, - // Slider, - // Color, - // // DATE & TIME - // Date_, - // DateTime, - // Duration, - // // FILE - // Image_, - // File_, - // // CONNECTION + /** TEXT */ + ShortText, + LongText, + RichText, + Email, + Phone, + Url, + /** SELECT */ + SingleSelect, + MultiSelect, + /** NUMERIC */ + Number_, + Checkbox, + Percentage, + Rating, + Slider, + Color, + /** DATE & TIME */ + Date_, + DateTime, + Duration, + /** FILE */ + Image_, + File_, + /** CONNECTION */ // Connector, // SubTable, // ConnectTable, // ConnectService, - // // CODE + /** CODE */ // Json, // Code, - // // CLOUD FUNCTION + /** CLOUD FUNCTION */ // Action, // Derivative, // // Aggregate, // Status, - // // AUDITING - // CreatedBy, - // UpdatedBy, - // CreatedAt, - // UpdatedAt, - // // METADATA - // User, - // Id, + /** AUDITING */ + CreatedBy, + UpdatedBy, + CreatedAt, + UpdatedAt, + /** METADATA */ + User, + Id, ]; /** @@ -122,12 +123,7 @@ export const hasDataTypes = (dataTypes: string[]) => { ); }; -export const getColumnType = (column: { - type: FieldType; - config: { - renderFieldType: FieldType; - }; -}) => +export const getColumnType = (column: ColumnConfig) => column.type === FieldType.derivative - ? column.config.renderFieldType + ? column.config?.renderFieldType : column.type; diff --git a/src/components/fields/types.ts b/src/components/fields/types.ts index 41716eb0..ea42fa7f 100644 --- a/src/components/fields/types.ts +++ b/src/components/fields/types.ts @@ -2,9 +2,10 @@ import { FieldType } from "@src/constants/fields"; import { FormatterProps, EditorProps } from "react-data-grid"; import { Control, UseFormReturn } from "react-hook-form"; import { PopoverProps } from "@mui/material"; -import { DocumentReference, WhereFilterOp } from "firebase/firestore"; +import { WhereFilterOp } from "firebase/firestore"; +import { TableRow, TableRowRef } from "@src/types/table"; // import { SelectedCell } from "@src/atoms/ContextMenu"; -// import { IContextMenuActions } from "./_BasicCell/BasicCellContextMenuActions"; +import { IContextMenuActions } from "./_BasicCell/BasicCellContextMenuActions"; export { FieldType }; @@ -19,12 +20,12 @@ export interface IFieldConfig { icon?: React.ReactNode; description?: string; setupGuideLink?: string; - // contextMenuActions?: ( - // selectedCell: SelectedCell, - // reset: () => Promise - // ) => IContextMenuActions[]; - TableCell: React.ComponentType>; - TableEditor: React.ComponentType>; + contextMenuActions?: ( + selectedCell: any, // FIXME: SelectedCell, + reset: () => Promise + ) => IContextMenuActions[]; + TableCell: React.ComponentType>; + TableEditor: React.ComponentType>; SideDrawerField: React.ComponentType; settings?: React.ComponentType; settingsValidator?: (config: Record) => Record; @@ -44,10 +45,12 @@ export interface IBasicCellProps { type: FieldType; name: string; } -export interface IHeavyCellProps extends IBasicCellProps, FormatterProps { - column: FormatterProps["column"] & { config?: Record }; +export interface IHeavyCellProps + extends IBasicCellProps, + FormatterProps { + column: FormatterProps["column"] & { config?: Record }; onSubmit: (value: any) => void; - docRef: DocumentReference; + docRef: TableRowRef; disabled: boolean; } @@ -59,9 +62,9 @@ export interface IPopoverCellProps extends IPopoverInlineCellProps { } export interface ISideDrawerFieldProps { - column: FormatterProps["column"] & { config?: Record }; + column: FormatterProps["column"] & { config?: Record }; control: Control; - docRef: DocumentReference; + docRef: TableRowRef; disabled: boolean; useFormMethods: UseFormReturn; } diff --git a/src/hooks/useCombinedRefs.ts b/src/hooks/useCombinedRefs.ts deleted file mode 100644 index f4d225d9..00000000 --- a/src/hooks/useCombinedRefs.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useCallback, Ref } from "react"; - -// From https://github.com/adazzle/react-data-grid/blob/main/src/hooks/useCombinedRefs.ts -// The MIT License (MIT) - -// Original work Copyright (c) 2014 Prometheus Research -// Modified work Copyright 2015 Adazzle - -// For the original source code please see https://github.com/prometheusresearch-archive/react-grid - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -export default function useCombinedRefs(...refs: readonly Ref[]) { - return useCallback( - (handle: T | null) => { - for (const ref of refs) { - if (typeof ref === "function") { - ref(handle); - } else if (ref !== null) { - // @ts-expect-error: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31065 - ref.current = handle; - } - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - refs - ); -} diff --git a/src/hooks/useFirebaseStorageUploader.tsx b/src/hooks/useFirebaseStorageUploader.tsx new file mode 100644 index 00000000..a13fcfee --- /dev/null +++ b/src/hooks/useFirebaseStorageUploader.tsx @@ -0,0 +1,183 @@ +import { useReducer } from "react"; +import { useAtom } from "jotai"; +import { useSnackbar } from "notistack"; +import type { DocumentReference } from "firebase/firestore"; +import { + ref, + uploadBytesResumable, + deleteObject, + getDownloadURL, +} from "firebase/storage"; + +import { Paper, Button } from "@mui/material"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; + +import { globalScope } from "@src/atoms/globalScope"; +import { firebaseStorageAtom } from "@src/sources/ProjectSourceFirebase"; +import { WIKI_LINKS } from "@src/constants/externalLinks"; + +export type UploaderState = { + progress: number; + isLoading: boolean; + error?: string; +}; +export type FileValue = { + ref: string; + downloadURL: string; + name: string; + type: string; + lastModifiedTS: number; +}; + +const initialState: UploaderState = { progress: 0, isLoading: false }; +const uploadReducer = ( + prevState: UploaderState, + newProps: Partial +) => ({ ...prevState, ...newProps }); + +export type UploadProps = { + docRef: DocumentReference; + fieldName: string; + files: File[]; + previousValue?: FileValue[]; + onComplete?: (values: FileValue[]) => void; +}; + +// TODO: GENERALIZE INTO ATOM +const useFirebaseStorageUploader = () => { + const [firebaseStorage] = useAtom(firebaseStorageAtom, globalScope); + const { enqueueSnackbar } = useSnackbar(); + + const [uploaderState, uploaderDispatch] = useReducer(uploadReducer, { + ...initialState, + }); + + const upload = ({ + docRef, + fieldName, + files, + previousValue, + onComplete, + }: UploadProps) => { + uploaderDispatch({ isLoading: true }); + + files.forEach((file) => { + const storageRef = ref( + firebaseStorage, + `${docRef.path}/${fieldName}/${file.name}` + ); + const uploadTask = uploadBytesResumable(storageRef, file); + + uploadTask.on( + // event + "state_changed", + // observer + (snapshot) => { + // Get task progress, including the number of bytes uploaded and the total number of bytes to be uploaded + const progress = + (snapshot.bytesTransferred / snapshot.totalBytes) * 100; + uploaderDispatch({ progress }); + console.log("Upload is " + progress + "% done"); + + switch (snapshot.state) { + case "paused": + console.log("Upload is paused"); + break; + case "running": + console.log("Upload is running"); + break; + } + }, + + // error – must be any to access error.code + (error: any) => { + // A full list of error codes is available at + // https://firebase.google.com/docs/storage/web/handle-errors + switch (error.code) { + case "storage/unknown": + // Unknown error occurred, inspect error.serverResponse + enqueueSnackbar("Unknown error occurred", { variant: "error" }); + uploaderDispatch({ error: error.serverResponse }); + break; + + case "storage/unauthorized": + // User doesn't have permission to access the object + enqueueSnackbar("You don’t have permissions to upload files", { + variant: "error", + action: ( + + + + ), + }); + uploaderDispatch({ error: error.code }); + break; + + case "storage/canceled": + // User canceled the upload + uploaderDispatch({ error: error.code }); + break; + + default: + uploaderDispatch({ error: error.code }); + // Unknown error occurred, inspect error.serverResponse + break; + } + + uploaderDispatch({ isLoading: false }); + }, + + // complete + () => { + uploaderDispatch({ isLoading: false }); + + // Upload completed successfully, now we can get the download URL + getDownloadURL(uploadTask.snapshot.ref).then( + (downloadURL: string) => { + const newValue: FileValue[] = Array.isArray(previousValue) + ? previousValue + : []; + + newValue.push({ + ref: uploadTask.snapshot.ref.fullPath, + downloadURL, + name: file.name, + type: file.type, + lastModifiedTS: file.lastModified, + }); + // STore in the document if docRef provided + // if (docRef && docRef.update)docRef.update({ [fieldName]: newValue }); + // Also call callback if it exists + // IMPORTANT: SideDrawer form may not update its local values after this + // function updates the doc, so you MUST update it manually + // using this callback + if (onComplete) onComplete(newValue); + } + ); + } + ); + }); + }; + + const deleteUpload = (fileValue: FileValue) => { + if (fileValue.ref) return deleteObject(ref(firebaseStorage, fileValue.ref)); + else { + return true; + } + }; + + return { uploaderState, upload, uploaderDispatch, deleteUpload }; +}; + +export default useFirebaseStorageUploader; diff --git a/src/hooks/useFirestoreDocWithAtom.ts b/src/hooks/useFirestoreDocWithAtom.ts index 17b83f20..eacf1563 100644 --- a/src/hooks/useFirestoreDocWithAtom.ts +++ b/src/hooks/useFirestoreDocWithAtom.ts @@ -50,7 +50,7 @@ export function useFirestoreDocWithAtom( const setDataAtom = useSetAtom(dataAtom, dataScope); const setUpdateDataAtom = useSetAtom( options?.updateDataAtom || (dataAtom as any), - globalScope + dataScope ); const handleError = useErrorHandler(); diff --git a/src/pages/Table.tsx b/src/pages/Table.tsx index 0e00007f..c32bc04e 100644 --- a/src/pages/Table.tsx +++ b/src/pages/Table.tsx @@ -5,8 +5,8 @@ import { isEmpty } from "lodash-es"; import { Fade } from "@mui/material"; -import TableHeaderSkeleton from "@src/components/Table/Skeleton/TableHeaderSkeleton"; -import HeaderRowSkeleton from "@src/components/Table/Skeleton/HeaderRowSkeleton"; +import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton"; +import HeaderRowSkeleton from "@src/components/Table/HeaderRowSkeleton"; import EmptyTable from "@src/components/Table/EmptyTable"; import Table from "@src/components/Table"; @@ -35,7 +35,11 @@ function TablePage() { ); - return ; + return ( + // Loading rows…}> +
+ // + ); } export default function ProvidedTablePage() { @@ -46,13 +50,13 @@ export default function ProvidedTablePage() { - + } > - + } diff --git a/src/sources/ProjectSourceFirebase/init.ts b/src/sources/ProjectSourceFirebase/init.ts index 5b46692e..3c1b27ec 100644 --- a/src/sources/ProjectSourceFirebase/init.ts +++ b/src/sources/ProjectSourceFirebase/init.ts @@ -6,6 +6,7 @@ import { connectFirestoreEmulator, enableMultiTabIndexedDbPersistence, } from "firebase/firestore"; +import { getStorage, connectStorageEmulator } from "firebase/storage"; export const envConfig = { apiKey: process.env.REACT_APP_FIREBASE_PROJECT_WEB_API_KEY, @@ -63,3 +64,16 @@ export const firebaseDbAtom = atom((get) => { } return db; }); + +/** + * Store Firebase Storage instance for current app. + * Connects to emulators based on env vars. + */ +export const firebaseStorageAtom = atom((get) => { + const storage = getStorage(get(firebaseAppAtom)); + if (!(window as any).firebaseStorageEmulatorStarted) { + if (envConnectEmulators) connectStorageEmulator(storage, "localhost", 9199); + (window as any).firebaseStorageEmulatorStarted = true; + } + return storage; +}); diff --git a/src/theme/components.tsx b/src/theme/components.tsx index 37d98449..c43f2385 100644 --- a/src/theme/components.tsx +++ b/src/theme/components.tsx @@ -1221,6 +1221,18 @@ export const components = (theme: Theme): ThemeOptions => { }, }, + MuiAvatar: { + styleOverrides: { + root: { + ...theme.typography.button, + }, + colorDefault: { + backgroundColor: theme.palette.action.selected, + color: theme.palette.text.secondary, + }, + }, + }, + MuiYearPicker: { styleOverrides: { root: { diff --git a/src/types/table.d.ts b/src/types/table.d.ts index 7f406282..c7c72e06 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -99,6 +99,8 @@ export type ColumnConfig = { script?: string; dynamicValueFn?: string; }; + /** FieldType to render for Derivative fields */ + renderFieldType?: FieldType; /** Column-specific config */ [key: string]: any; diff --git a/src/utils/color.ts b/src/utils/color.ts new file mode 100644 index 00000000..819be165 --- /dev/null +++ b/src/utils/color.ts @@ -0,0 +1,14 @@ +import { colord, extend } from "colord"; +import mixPlugin from "colord/plugins/mix"; +extend([mixPlugin]); + +export const resultColors = { + No: "#ED4747", + Maybe: "#f3c900", + Yes: "#1fad5f", +}; + +export const resultColorsScale = (value: number) => + value <= 0.5 + ? colord(resultColors.No).mix(resultColors.Maybe, value * 2) + : colord(resultColors.Maybe).mix(resultColors.Yes, (value - 0.5) * 2); diff --git a/yarn.lock b/yarn.lock index bcfddfd8..4828ee7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1895,6 +1895,11 @@ resolved "https://registry.yarnpkg.com/@date-io/core/-/core-2.13.1.tgz#f041765aff5c55fbc7e37fdd75fc1792733426d6" integrity sha512-pVI9nfkf2qClb2Cxdq0Q4zJhdawMG4ybWZUVGifT78FDwzRMX2SwXBb55s5NRJk0HcIicDuxktmCtemZqMH1Zg== +"@date-io/core@^2.14.0": + version "2.14.0" + resolved "https://registry.yarnpkg.com/@date-io/core/-/core-2.14.0.tgz#03e9b9b9fc8e4d561c32dd324df0f3ccd967ef14" + integrity sha512-qFN64hiFjmlDHJhu+9xMkdfDG2jLsggNxKXglnekUpXSq8faiqZgtHm2lsHCUuaPDTV6wuXHcCl8J1GQ5wLmPw== + "@date-io/date-fns@^2.11.0": version "2.13.1" resolved "https://registry.yarnpkg.com/@date-io/date-fns/-/date-fns-2.13.1.tgz#19d8a245dab61c03c95ba492d679d98d2b0b4af5" @@ -1902,6 +1907,13 @@ dependencies: "@date-io/core" "^2.13.1" +"@date-io/date-fns@^2.14.0": + version "2.14.0" + resolved "https://registry.yarnpkg.com/@date-io/date-fns/-/date-fns-2.14.0.tgz#92ab150f488f294c135c873350d154803cebdbea" + integrity sha512-4fJctdVyOd5cKIKGaWUM+s3MUXMuzkZaHuTY15PH70kU1YTMrCoauA7hgQVx9qj0ZEbGrH9VSPYJYnYro7nKiA== + dependencies: + "@date-io/core" "^2.14.0" + "@date-io/dayjs@^2.11.0": version "2.13.1" resolved "https://registry.yarnpkg.com/@date-io/dayjs/-/dayjs-2.13.1.tgz#98461d22ee98179b9f2dca3b36f1b618704ae593" @@ -1909,6 +1921,13 @@ dependencies: "@date-io/core" "^2.13.1" +"@date-io/dayjs@^2.14.0": + version "2.14.0" + resolved "https://registry.yarnpkg.com/@date-io/dayjs/-/dayjs-2.14.0.tgz#8d4e93e1d473bb5f25210866204dc33384ca4c20" + integrity sha512-4fRvNWaOh7AjvOyJ4h6FYMS7VHLQnIEeAV5ahv6sKYWx+1g1UwYup8h7+gPuoF+sW2hTScxi7PVaba2Jk/U8Og== + dependencies: + "@date-io/core" "^2.14.0" + "@date-io/luxon@^2.11.1": version "2.13.1" resolved "https://registry.yarnpkg.com/@date-io/luxon/-/luxon-2.13.1.tgz#3701b3cabfffda5102af302979aa6e58acfda91a" @@ -1916,6 +1935,13 @@ dependencies: "@date-io/core" "^2.13.1" +"@date-io/luxon@^2.14.0": + version "2.14.0" + resolved "https://registry.yarnpkg.com/@date-io/luxon/-/luxon-2.14.0.tgz#cd1641229e00a899625895de3a31e3aaaf66629f" + integrity sha512-KmpBKkQFJ/YwZgVd0T3h+br/O0uL9ZdE7mn903VPAG2ZZncEmaUfUdYKFT7v7GyIKJ4KzCp379CRthEbxevEVg== + dependencies: + "@date-io/core" "^2.14.0" + "@date-io/moment@^2.11.0": version "2.13.1" resolved "https://registry.yarnpkg.com/@date-io/moment/-/moment-2.13.1.tgz#122a51e4bdedf71ff3babb264427737dc022c1e6" @@ -1923,6 +1949,13 @@ dependencies: "@date-io/core" "^2.13.1" +"@date-io/moment@^2.14.0": + version "2.14.0" + resolved "https://registry.yarnpkg.com/@date-io/moment/-/moment-2.14.0.tgz#8300abd6ae8c55d8edee90d118db3cef0b1d4f58" + integrity sha512-VsoLXs94GsZ49ecWuvFbsa081zEv2xxG7d+izJsqGa2L8RPZLlwk27ANh87+SNnOUpp+qy2AoCAf0mx4XXhioA== + dependencies: + "@date-io/core" "^2.14.0" + "@emotion/babel-plugin@^11.7.1": version "11.7.2" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.7.2.tgz#fec75f38a6ab5b304b0601c74e2a5e77c95e5fa0" @@ -2890,6 +2923,17 @@ prop-types "^15.7.2" react-is "^17.0.2" +"@mui/utils@^5.7.0": + version "5.8.0" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.8.0.tgz#4b1d19cbcf70773283375e763b7b3552b84cb58f" + integrity sha512-7LgUtCvz78676iC0wpTH7HizMdCrTphhBmRWimIMFrp5Ph6JbDFVuKS1CwYnWWxRyYKL0QzXrDL0lptAU90EXg== + dependencies: + "@babel/runtime" "^7.17.2" + "@types/prop-types" "^15.7.5" + "@types/react-is" "^16.7.1 || ^17.0.0" + prop-types "^15.8.1" + react-is "^17.0.2" + "@mui/x-date-pickers@5.0.0-alpha.0": version "5.0.0-alpha.0" resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-5.0.0-alpha.0.tgz#a62ffbab453d3c2dcd4ec20bd4f3f6338ad2ed3f" @@ -2905,6 +2949,22 @@ react-transition-group "^4.4.2" rifm "^0.12.1" +"@mui/x-date-pickers@^5.0.0-alpha.4": + version "5.0.0-alpha.4" + resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-5.0.0-alpha.4.tgz#853710d09b2dc29876e61a32d1f7b5c4b6d8816a" + integrity sha512-bPEsVygOI5KvrySYzi4ujJlRr4uskM5hDpcV8JCafHtSNQjUMQmCDtQKpAd8rViKBCBQMK8vhpqmf8ShfiZpLA== + dependencies: + "@babel/runtime" "^7.17.2" + "@date-io/date-fns" "^2.14.0" + "@date-io/dayjs" "^2.14.0" + "@date-io/luxon" "^2.14.0" + "@date-io/moment" "^2.14.0" + "@mui/utils" "^5.7.0" + clsx "^1.1.1" + prop-types "^15.7.2" + react-transition-group "^4.4.2" + rifm "^0.12.1" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -3367,6 +3427,14 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.0.4.tgz#5b430a9c27f25078bff4471661b755115d0db9d4" integrity sha512-VBZe5lcUsmrQyOwIFvqOxLBoaTw1/Qy4Ek+VgmFYs719bs2SxUp42vbsb7ATlQDkHdj4OIQlucfpwxe5WoG1jA== +"@tinymce/tinymce-react@^3": + version "3.14.0" + resolved "https://registry.yarnpkg.com/@tinymce/tinymce-react/-/tinymce-react-3.14.0.tgz#0883df0532b5916b41066f8dc55df09a2d1aa1d0" + integrity sha512-1X3Kl4DNVG/XNttlniQHvb9awX2MrD7XaFO2nWZ9SJrionIqWqKMLVl5GnJ8Br6KehNl97amxO8t3+5eLvfgxg== + dependencies: + prop-types "^15.6.2" + tinymce "^5.5.1" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -3708,7 +3776,7 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.4.tgz#5d9b63132df54d8909fce1c3f8ca260fdd693e17" integrity sha512-ReVR2rLTV1kvtlWFyuot+d1pkpG2Fw/XKE3PDAdj57rbM97ttSp9JZ2UsP+2EHTylra9cUf6JA7tGwW1INzUrA== -"@types/prop-types@*", "@types/prop-types@^15.0.0", "@types/prop-types@^15.7.4": +"@types/prop-types@*", "@types/prop-types@^15.0.0", "@types/prop-types@^15.7.4", "@types/prop-types@^15.7.5": version "15.7.5" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== @@ -10629,6 +10697,11 @@ react-hook-form@^7.10.0, react-hook-form@^7.30.0: resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.30.0.tgz#c9e2fd54d3627e43bd94bf38ef549df2e80c1371" integrity sha512-DzjiM6o2vtDGNMB9I4yCqW8J21P314SboNG1O0obROkbg7KVS0I7bMtwSdKyapnCPjHgnxc3L7E5PEdISeEUcQ== +react-image@^4: + version "4.0.3" + resolved "https://registry.yarnpkg.com/react-image/-/react-image-4.0.3.tgz#6fa722877660b67295298a914bff1ed87ad2cf83" + integrity sha512-19MUK9u1qaw9xys8XEsVkSpVhHctEBUeYFvrLTe1PN+4w5Co13AN2WA7xtBshPM6SthsOj77SlDrEAeOaJpf7g== + react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -12045,6 +12118,11 @@ tiny-warning@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== +tinymce@^5, tinymce@^5.5.1: + version "5.10.4" + resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-5.10.4.tgz#24ee843c7648ade708605dec15d8dad07809f7db" + integrity sha512-L0ivAhGu7bEo6cUBrCzhtKlkIQqG2sTcL+uu7soMSxrECQIC5VwUnzp9HCEf+fRl36q6zavLV48lf8jelj+gXA== + tmp@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" @@ -12435,6 +12513,11 @@ urijs@^1.19.1: resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.11.tgz#204b0d6b605ae80bea54bea39280cdb7c9f923cc" integrity sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ== +use-algolia@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/use-algolia/-/use-algolia-1.5.3.tgz#7a194144a0050665e2134d18974a9408a9dae176" + integrity sha512-UKjc0ptcIpgtetUa+HvU5WFDILR62sRzXpgEhoTn+FjRK3P+FAUT5gbypvx12Iu/oCRVrUmFo/Qyxe5ESbUCSg== + use-debounce@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-8.0.0.tgz#fd8d24858dd0d1986bb9a45af4d0aa6ce99226d2"