diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..372362317 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +yarn lint-staged diff --git a/.prettierignore b/.prettierignore index 63022880d..9ede4117e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ node_modules/ .yarn +emulators/ diff --git a/craco.config.js b/craco.config.js index 46000fac9..93d843494 100644 --- a/craco.config.js +++ b/craco.config.js @@ -1,3 +1,4 @@ +const { whenDev } = require("@craco/craco"); const CracoAlias = require("craco-alias"); const CracoSwcPlugin = require("craco-swc"); @@ -11,20 +12,43 @@ module.exports = { tsConfigPath: "./tsconfig.extend.json", }, }, - { - plugin: CracoSwcPlugin, - options: { - swcLoaderOptions: { - jsc: { - target: "es2019", - transform: { - react: { - runtime: "automatic", + // Use Babel on dev since Jotai doesn’t have swc plugins yet + // See https://github.com/pmndrs/jotai/discussions/1057 + // Use swc on production and test since Babel seems to break Jest + ...whenDev( + () => [], + [ + { + plugin: CracoSwcPlugin, + options: { + swcLoaderOptions: { + jsc: { + target: "es2021", + transform: { + react: { + runtime: "automatic", + }, + }, }, }, }, }, - }, - }, + ] + ), ], + babel: { + plugins: [ + "jotai/babel/plugin-debug-label", + "./node_modules/jotai/babel/plugin-react-refresh", + ], + }, + jest: { + configure: (jestConfig) => { + jestConfig.setupFilesAfterEnv = ["./src/test/setupTests.ts"]; + jestConfig.forceExit = true; // jest hangs if we don't have this + + jestConfig.moduleNameMapper["^lodash-es$"] = "lodash"; + return jestConfig; + }, + }, }; diff --git a/emulators/auth_export/accounts.json b/emulators/auth_export/accounts.json new file mode 100644 index 000000000..b22a33c3a --- /dev/null +++ b/emulators/auth_export/accounts.json @@ -0,0 +1 @@ +{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"26CJMrwlouNRwkiLofNK07DNgKhw","createdAt":"1651022832613","lastLoginAt":"1651630548960","displayName":"Admin User","photoUrl":"","customAttributes":"{\"roles\": [\"ADMIN\"]}","providerUserInfo":[{"providerId":"google.com","rawId":"abc123","federatedId":"abc123","displayName":"Admin User","email":"admin@example.com"}],"validSince":"1651630530","email":"admin@example.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2022-05-04T02:15:48.960Z"},{"localId":"3xTRVPnJGT2GE6lkiWKZp1jShuXj","createdAt":"1651023059442","lastLoginAt":"1651223181908","displayName":"Editor User","providerUserInfo":[{"providerId":"google.com","rawId":"1535779573397289142795231390488730790451","federatedId":"1535779573397289142795231390488730790451","displayName":"Editor User","email":"editor@example.com"}],"validSince":"1651630530","email":"editor@example.com","emailVerified":true,"disabled":false}]} \ No newline at end of file diff --git a/emulators/auth_export/config.json b/emulators/auth_export/config.json new file mode 100644 index 000000000..bb253cf77 --- /dev/null +++ b/emulators/auth_export/config.json @@ -0,0 +1 @@ +{"signIn":{"allowDuplicateEmails":false},"usageMode":"DEFAULT"} \ No newline at end of file diff --git a/emulators/firebase-export-metadata.json b/emulators/firebase-export-metadata.json new file mode 100644 index 000000000..6b385ef30 --- /dev/null +++ b/emulators/firebase-export-metadata.json @@ -0,0 +1,12 @@ +{ + "version": "10.6.0", + "firestore": { + "version": "1.14.1", + "path": "firestore_export", + "metadata_file": "firestore_export/firestore_export.overall_export_metadata" + }, + "auth": { + "version": "10.6.0", + "path": "auth_export" + } +} \ No newline at end of file diff --git a/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata new file mode 100644 index 000000000..9e8cd87dc Binary files /dev/null and b/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ diff --git a/emulators/firestore_export/all_namespaces/all_kinds/output-0 b/emulators/firestore_export/all_namespaces/all_kinds/output-0 new file mode 100644 index 000000000..78e1cac6b Binary files /dev/null and b/emulators/firestore_export/all_namespaces/all_kinds/output-0 differ diff --git a/emulators/firestore_export/firestore_export.overall_export_metadata b/emulators/firestore_export/firestore_export.overall_export_metadata new file mode 100644 index 000000000..a87e2efab Binary files /dev/null and b/emulators/firestore_export/firestore_export.overall_export_metadata differ diff --git a/firebase.json b/firebase.json index cfbc74ce7..26e83bd45 100644 --- a/firebase.json +++ b/firebase.json @@ -8,5 +8,26 @@ "destination": "/index.html" } ] + }, + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "storage": { + "rules": "storage.rules" + }, + "emulators": { + "auth": { + "port": 9099 + }, + "firestore": { + "port": 9299 + }, + "storage": { + "port": 9199 + }, + "ui": { + "enabled": true + } } } diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 000000000..415027e5d --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 000000000..91451d695 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,36 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + // Allow admins to read and write all documents + match /{document=**} { + allow read, write: if hasAnyRole(["ADMIN", "OWNER"]); + } + + // Rowy: Allow signed in users to read Rowy configuration and admins to write + match /_rowy_/{docId} { + allow read: if request.auth.token.roles.size() > 0; + allow write: if hasAnyRole(["ADMIN", "OWNER"]); + match /{document=**} { + allow read: if request.auth.token.roles.size() > 0; + allow write: if hasAnyRole(["ADMIN", "OWNER"]); + } + } + // Rowy: Allow users to edit their settings + match /_rowy_/userManagement/users/{userId} { + allow get, update, delete: if isDocOwner(userId); + allow create: if request.auth != null; + } + // Rowy: Allow public to read public Rowy configuration + match /_rowy_/publicSettings { + allow get: if true; + } + + // Rowy: Utility functions + function isDocOwner(docId) { + return request.auth != null && (request.auth.uid == resource.id || request.auth.uid == docId); + } + function hasAnyRole(roles) { + return request.auth != null && request.auth.token.roles.hasAny(roles); + } + } +} diff --git a/package.json b/package.json index f86fd68a1..2a2aeeef4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rowy", - "version": "2.5.0", + "version": "2.6.0-alpha.0", "homepage": "https://rowy.io", "repository": { "type": "git", @@ -8,94 +8,106 @@ }, "private": true, "dependencies": { - "@craco/craco": "^6.2.0", - "@date-io/date-fns": "1.x", - "@emotion/react": "^11.4.0", - "@emotion/styled": "^11.3.0", - "@hookform/resolvers": "^2.8.5", - "@mdi/js": "^6.5.95", - "@monaco-editor/react": "^4.3.1", - "@mui/icons-material": "^5.5.1", - "@mui/lab": "^5.0.0-alpha.73", - "@mui/material": "^5.5.1", - "@mui/styles": "^5.5.1", - "@rowy/form-builder": "^0.5.3", - "@rowy/multiselect": "^0.2.3", - "@tinymce/tinymce-react": "^3.12.6", - "algoliasearch": "^4.8.6", - "ansi-to-react": "^6.1.5", - "colord": "^2.7.0", - "compare-versions": "^4.1.1", - "craco-swc": "^0.1.3", - "csv-parse": "^4.15.3", - "date-fns": "^2.19.0", - "dompurify": "^2.2.6", - "file-saver": "^2.0.5", - "firebase": "8.6.8", - "hotkeys-js": "^3.7.2", - "jotai": "^1.5.3", + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mdi/js": "^6.6.96", + "@monaco-editor/react": "^4.4.4", + "@mui/icons-material": "^5.6.0", + "@mui/lab": "^5.0.0-alpha.76", + "@mui/material": "^5.6.0", + "@mui/styles": "^5.6.2", + "@rowy/form-builder": "^0.5.5", + "@rowy/multiselect": "^0.3.0", + "compare-versions": "^4.1.3", + "date-fns": "^2.28.0", + "dompurify": "^2.3.6", + "firebase": "^9.6.11", + "firebaseui": "^6.0.1", + "jotai": "^1.6.5", "json-stable-stringify-without-jsonify": "^1.0.1", - "json2csv": "^5.0.6", - "jszip": "^3.6.0", - "jwt-decode": "^3.1.2", - "lodash": "^4.17.21", + "lodash-es": "^4.17.21", "match-sorter": "^6.3.1", - "notistack": "^2.0.2", - "pb-util": "^1.0.1", - "query-string": "^6.8.3", - "quicktype-core": "^6.0.70", - "react": "^17.0.2", - "react-beautiful-dnd": "^13.0.0", - "react-color-palette": "^6.1.0", - "react-data-grid": "^7.0.0-beta.5", - "react-div-100vh": "^0.6.0", - "react-dnd": "^11.1.3", - "react-dnd-html5-backend": "^11.1.3", - "react-dom": "^17.0.2", - "react-dropzone": "^10.1.8", + "notistack": "^2.0.4", + "quicktype-core": "^6.0.71", + "react": "^18.0.0", + "react-color-palette": "^6.2.0", + "react-data-grid": "7.0.0-beta.5", + "react-div-100vh": "^0.7.0", + "react-dom": "^18.0.0", "react-element-scroll-hook": "^1.1.0", - "react-firebaseui": "^5.0.2", - "react-helmet": "^6.1.0", - "react-hook-form": "^7.21.2", - "react-image": "^4.0.3", - "react-json-view": "^1.19.1", - "react-markdown": "^8.0.0", - "react-router-dom": "^5.0.1", + "react-error-boundary": "^3.1.4", + "react-helmet-async": "^1.3.0", + "react-hook-form": "^7.30.0", + "react-markdown": "^8.0.3", + "react-router-dom": "^6.3.0", "react-router-hash-link": "^2.4.3", - "react-scripts": "^4.0.3", - "react-usestateref": "^1.0.5", + "react-scripts": "^5.0.0", "remark-gfm": "^3.0.1", - "serve": "^11.3.2", - "swr": "^1.0.1", - "tinymce": "^5.10.0", - "typescript": "^4.4.2", - "use-algolia": "^1.4.1", - "use-debounce": "^3.3.0", - "use-persisted-state": "^0.3.3", - "yarn": "^1.22.10" + "swr": "^1.3.0", + "tss-react": "^3.6.2", + "typescript": "^4.6.3", + "use-debounce": "^7.0.1", + "web-vitals": "^2.1.4" }, "scripts": { - "upstream": "git fetch upstream;git merge upstream/main;git commit -m'merge upstream';git push", - "serve": "serve -s build", - "start": "craco start", - "build": "craco build CI=false", - "test": "craco test --env=jsdom", - "eject": "craco eject", + "start": "cross-env PORT=7699 craco start", + "startWithEmulator": "cross-env PORT=7699 REACT_APP_FIREBASE_EMULATOR=true craco start", + "emulators": "firebase emulators:start --only firestore,auth --import ./emulators/ --export-on-exit", + "test": "craco test --env ./src/test/custom-jest-env.js", + "build": "craco build", + "analyze": "source-map-explorer ./build/static/js/*.js", + "prepare": "husky install", "env": "node createDotEnv", "target": "firebase target:apply hosting rowy", - "deploy": "firebase deploy" + "deploy": "firebase deploy --only hosting" }, "engines": { - "node": ">=10" + "node": ">=16" }, "eslintConfig": { - "extends": "react-app" + "plugins": [ + "eslint-plugin-no-relative-import-paths", + "eslint-plugin-tsdoc", + "eslint-plugin-local-rules" + ], + "extends": [ + "react-app", + "react-app/jest", + "prettier" + ], + "rules": { + "no-relative-import-paths/no-relative-import-paths": [ + "error", + { + "allowSameFolder": true + } + ], + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "lodash", + "lodash/*" + ], + "message": "Use lodash-es instead" + } + ] + } + ], + "tsdoc/syntax": "warn", + "local-rules/no-jotai-use-atom-without-scope": "error" + } }, "browserslist": { "production": [ - ">0.2%", + "> 0.5%", "not dead", - "not op_mini all" + "not op_mini all", + "not ie > 0", + "not and_uc > 0", + "not ios_saf < 14" ], "development": [ "last 1 chrome version", @@ -104,34 +116,41 @@ ] }, "devDependencies": { - "@types/dompurify": "^2.2.1", - "@types/file-saver": "^2.0.1", - "@types/lodash": "^4.14.168", - "@types/node": "^14.14.6", - "@types/react": "^17.0.11", - "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-color": "^3.0.1", - "@types/react-div-100vh": "^0.3.0", - "@types/react-dom": "^17.0.8", - "@types/react-helmet": "^6.1.2", - "@types/react-router-dom": "^5.1.7", - "@types/react-router-hash-link": "^2.4.1", - "@types/use-persisted-state": "^0.3.0", + "@craco/craco": "^6.4.3", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^13.0.0", + "@testing-library/user-event": "^14.0.4", + "@types/dompurify": "^2.3.3", + "@types/jest": "^27.4.1", + "@types/lodash-es": "^4.17.6", + "@types/node": "^17.0.23", + "@types/react": "^18.0.5", + "@types/react-div-100vh": "^0.4.0", + "@types/react-dom": "^18.0.0", + "@types/react-router-dom": "^5.3.3", + "@types/react-router-hash-link": "^2.4.5", + "@typescript-eslint/parser": "^5.18.0", "craco-alias": "^3.0.1", - "firebase-tools": "^10.1.0", - "husky": "^4.2.5", - "monaco-editor": "^0.21.2", - "playwright": "^1.5.2", - "prettier": "^2.2.1", - "pretty-quick": "^3.0.0", - "raw-loader": "^4.0.2" + "craco-swc": "^0.5.1", + "cross-env": "^7.0.3", + "eslint": "^8.12.0", + "eslint-config-prettier": "^8.5.0", + "eslint-config-react-app": "^7.0.0", + "eslint-plugin-local-rules": "^1.1.0", + "eslint-plugin-no-relative-import-paths": "^1.2.0", + "eslint-plugin-tsdoc": "^0.2.16", + "husky": ">=7.0.4", + "lint-staged": ">=12.3.7", + "monaco-editor": "^0.33.0", + "prettier": "^2.6.2", + "raw-loader": "^4.0.2", + "source-map-explorer": "^2.5.2" }, "resolutions": { - "react-hook-form": "^7.21.2" + "@types/react": "^18" }, - "husky": { - "hooks": { - "pre-commit": "pretty-quick --staged" - } + "lint-staged": { + "*.{js,ts,tsx}": "eslint --cache --fix", + "**/*": "prettier --write --ignore-unknown" } } diff --git a/public/_redirects b/public/_redirects deleted file mode 100644 index 11cf5ed6e..000000000 --- a/public/_redirects +++ /dev/null @@ -1,2 +0,0 @@ -# Rewrite a path -/* /index.html 200 diff --git a/public/browserconfig.xml b/public/browserconfig.xml deleted file mode 100644 index bf8dc2673..000000000 --- a/public/browserconfig.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - #4200ff - - - diff --git a/public/logo192.png b/public/logo192.png new file mode 100644 index 000000000..fc44b0a37 Binary files /dev/null and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png new file mode 100644 index 000000000..a4e47a654 Binary files /dev/null and b/public/logo512.png differ diff --git a/public/robots.txt b/public/robots.txt index 01b0f9a10..e9e57dc4d 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,2 +1,3 @@ # https://www.robotstxt.org/robotstxt.html User-agent: * +Disallow: diff --git a/public/site.webmanifest b/public/site.webmanifest index 5ba4c7f1a..291e3eeee 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1,19 +1,19 @@ { - "name": "Rowy", - "short_name": "Rowy", - "icons": [ - { - "src": "/android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/android-chrome-512x512.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "theme_color": "#4200ff", - "background_color": "#4200ff", - "display": "standalone" + "name": "Rowy", + "short_name": "Rowy", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#4200ff", + "background_color": "#4200ff", + "display": "standalone" } diff --git a/public/static/meta.png b/public/static/meta.png index 702f613ba..305e8273e 100644 Binary files a/public/static/meta.png and b/public/static/meta.png differ diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index 23c181f02..000000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom"; -import App from "./App"; - -it("renders without crashing", () => { - const div = document.createElement("div"); - ReactDOM.render(, div); - ReactDOM.unmountComponentAtNode(div); -}); diff --git a/src/App.tsx b/src/App.tsx index eb610f7b3..bc68eafdc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,221 +1,119 @@ import { lazy, Suspense } from "react"; -import { Route, Switch, Redirect } from "react-router-dom"; -import LocalizationProvider from "@mui/lab/LocalizationProvider"; -import AdapterDateFns from "@mui/lab/AdapterDateFns"; +import { Routes, Route, Navigate } from "react-router-dom"; +import { useAtom } from "jotai"; -import { StyledEngineProvider } from "@mui/material/styles"; -import "./space-grotesk.css"; - -import CustomBrowserRouter from "@src/utils/CustomBrowserRouter"; -import PrivateRoute from "@src/utils/PrivateRoute"; -import ErrorBoundary from "@src/components/ErrorBoundary"; import Loading from "@src/components/Loading"; -import Navigation from "@src/components/Navigation"; -import Logo from "@src/assets/Logo"; +import ProjectSourceFirebase from "@src/sources/ProjectSourceFirebase"; +import ConfirmDialog from "@src/components/ConfirmDialog"; import RowyRunModal from "@src/components/RowyRunModal"; +import NotFound from "@src/pages/NotFound"; +import RequireAuth from "@src/layouts/RequireAuth"; +import Navigation from "@src/layouts/Navigation"; +import TableSettingsDialog from "@src/components/TableSettingsDialog"; -import SwrProvider from "@src/contexts/SwrContext"; -import ConfirmationProvider from "@src/components/ConfirmationDialog/Provider"; -import { AppProvider } from "@src/contexts/AppContext"; -import { ProjectContextProvider } from "@src/contexts/ProjectContext"; -import { SnackbarProvider } from "@src/contexts/SnackbarContext"; -import { SnackLogProvider } from "@src/contexts/SnackLogContext"; -import routes from "@src/constants/routes"; +import { globalScope, currentUserAtom } from "@src/atoms/globalScope"; +import { ROUTES } from "@src/constants/routes"; -import AuthPage from "@src/pages/Auth"; +import JotaiTestPage from "@src/pages/JotaiTest"; import SignOutPage from "@src/pages/Auth/SignOut"; -import SignUpPage from "@src/pages/Auth/SignUp"; -import TestPage from "@src/pages/Test"; -import RowyRunTestPage from "@src/pages/RowyRunTest"; -import PageNotFound from "@src/pages/PageNotFound"; - -import Favicon from "@src/assets/Favicon"; -import "@src/analytics"; // prettier-ignore -const AuthSetupGuidePage = lazy(() => import("@src/pages/Auth/SetupGuide" /* webpackChunkName: "AuthSetupGuide" */)); +const AuthPage = lazy(() => import("@src/pages/Auth/index" /* webpackChunkName: "AuthPage" */)); // prettier-ignore -const ImpersonatorAuthPage = lazy(() => import("./pages/Auth/ImpersonatorAuth" /* webpackChunkName: "ImpersonatorAuthPage" */)); +const SignUpPage = lazy(() => import("@src/pages/Auth/SignUp" /* webpackChunkName: "SignUpPage" */)); // prettier-ignore -const JwtAuthPage = lazy(() => import("./pages/Auth/JwtAuth" /* webpackChunkName: "JwtAuthPage" */)); - +const JwtAuthPage = lazy(() => import("@src/pages/Auth/JwtAuth" /* webpackChunkName: "JwtAuthPage" */)); // prettier-ignore -const HomePage = lazy(() => import("./pages/Home" /* webpackChunkName: "HomePage" */)); +const ImpersonatorAuthPage = lazy(() => import("@src/pages/Auth/ImpersonatorAuth" /* webpackChunkName: "ImpersonatorAuthPage" */)); + // prettier-ignore -const TablePage = lazy(() => import("./pages/Table" /* webpackChunkName: "TablePage" */)); +const SetupPage = lazy(() => import("@src/pages/Setup" /* webpackChunkName: "SetupPage" */)); // prettier-ignore -const ProjectSettingsPage = lazy(() => import("./pages/Settings/ProjectSettings" /* webpackChunkName: "ProjectSettingsPage" */)); +const TablesPage = lazy(() => import("@src/pages/Tables" /* webpackChunkName: "TablesPage" */)); // prettier-ignore -const UserSettingsPage = lazy(() => import("./pages/Settings/UserSettings" /* webpackChunkName: "UserSettingsPage" */)); +const TablePage = lazy(() => import("@src/pages/TableTest" /* webpackChunkName: "TablePage" */)); + // prettier-ignore -const UserManagementPage = lazy(() => import("./pages/Settings/UserManagement" /* webpackChunkName: "UserManagementPage" */)); +const UserSettingsPage = lazy(() => import("@src/pages/Settings/UserSettings" /* webpackChunkName: "UserSettingsPage" */)); // prettier-ignore -const SetupPage = lazy(() => import("@src/pages/Setup" /* webpackChunkName: "SetupPage" */)); +const ProjectSettingsPage = lazy(() => import("@src/pages/Settings/ProjectSettings" /* webpackChunkName: "ProjectSettingsPage" */)); +// prettier-ignore +const UserManagementPage = lazy(() => import("@src/pages/Settings/UserManagement" /* webpackChunkName: "UserManagementPage" */)); +// const RowyRunTestPage = lazy(() => import("@src/pages/RowyRunTest" /* webpackChunkName: "RowyRunTestPage" */)); export default function App() { + const [currentUser] = useAtom(currentUserAtom, globalScope); + return ( - - - - - - - - - - - - }> - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - } - /> - - ( - - - } - /> - } - /> - ( - - !(open && pinned) && ( - - ) - } - > - - - )} - /> - } - /> - } - /> - - ( - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - - - )} - /> - - } - /> - } /> - - - - - - - - - - - + }> + + + + + {currentUser === undefined ? ( + + ) : ( + + } /> + + } /> + } /> + } /> + } /> + + + + } + /> + + } /> + + + + + + + } + > + } + /> + } /> + + + } /> + } /> + + + } + /> + } /> + } + /> + } + /> + {/* } /> */} + + } /> + + + {/* } /> */} + + )} + ); } diff --git a/src/Providers.tsx b/src/Providers.tsx new file mode 100644 index 000000000..2093c1c5c --- /dev/null +++ b/src/Providers.tsx @@ -0,0 +1,47 @@ +import { ErrorBoundary } from "react-error-boundary"; +import ErrorFallback from "@src/components/ErrorFallback"; +import { BrowserRouter } from "react-router-dom"; +import { HelmetProvider } from "react-helmet-async"; +import { Provider, Atom } from "jotai"; +import { globalScope } from "@src/atoms/globalScope"; +import createCache from "@emotion/cache"; +import { CacheProvider } from "@emotion/react"; +import RowyThemeProvider from "@src/theme/RowyThemeProvider"; +import SnackbarProvider from "@src/contexts/SnackbarContext"; + +import { Suspense } from "react"; +import Loading from "@src/components/Loading"; + +export const muiCache = createCache({ key: "mui", prepend: true }); + +export interface IProvidersProps { + children: React.ReactNode; + initialAtomValues?: Iterable, unknown]>; +} + +export default function Providers({ + children, + initialAtomValues, +}: IProvidersProps) { + return ( + + + + + + + + + }> + {children} + + + + + + + + + + ); +} diff --git a/src/analytics.ts b/src/analytics.ts index bc30d2686..e72e629ae 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -1,5 +1,5 @@ -import firebase from "firebase/app"; -import "firebase/analytics"; +import { initializeApp } from "firebase/app"; +import { getAnalytics, logEvent } from "firebase/analytics"; const firebaseConfig = { apiKey: "AIzaSyArABiYGK7dZgwSk0pw_6vKbOt6U1ZRPpc", @@ -11,7 +11,6 @@ const firebaseConfig = { measurementId: "G-0VWE25LFZJ", }; -// Initialize Firebase -const rowyServiceApp = firebase.initializeApp(firebaseConfig, "rowy-service"); - -export const analytics = firebase.analytics(rowyServiceApp); +const rowyServiceApp = initializeApp(firebaseConfig, "rowy-service"); +export const analytics = getAnalytics(rowyServiceApp); +export { logEvent }; diff --git a/src/assets/BrandedBackground.tsx b/src/assets/BrandedBackground.tsx index febb3cd28..bad4b8757 100644 --- a/src/assets/BrandedBackground.tsx +++ b/src/assets/BrandedBackground.tsx @@ -1,19 +1,15 @@ -import Helmet from "react-helmet"; import { use100vh } from "react-div-100vh"; -import { useTheme, alpha } from "@mui/material/styles"; -import { Box, BoxProps } from "@mui/material"; +import { GlobalStyles, Box, BoxProps } from "@mui/material"; +import { alpha } from "@mui/material/styles"; import bgPattern from "@src/assets/bg-pattern.svg"; import bgPatternDark from "@src/assets/bg-pattern-dark.svg"; export default function BrandedBackground() { - const theme = useTheme(); - return ( - - - + /> ); } diff --git a/src/assets/LogoRowyRun.tsx b/src/assets/LogoRowyRun.tsx index c52bcecb5..04152653d 100644 --- a/src/assets/LogoRowyRun.tsx +++ b/src/assets/LogoRowyRun.tsx @@ -24,14 +24,14 @@ export default function LogoRowyRun({ Rowy Run diff --git a/src/assets/favicon.svg b/src/assets/favicon.svg new file mode 100644 index 000000000..91ab2bc11 --- /dev/null +++ b/src/assets/favicon.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/assets/icons/Firebase.tsx b/src/assets/icons/Firebase.tsx index 23076a3bc..5065c95ba 100644 --- a/src/assets/icons/Firebase.tsx +++ b/src/assets/icons/Firebase.tsx @@ -1,5 +1,5 @@ import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon"; -import { mdiFirebase } from '@mdi/js'; +import { mdiFirebase } from "@mdi/js"; export default function AddColumn(props: SvgIconProps) { return ( diff --git a/src/atoms/ContextMenu.ts b/src/atoms/ContextMenu.ts deleted file mode 100644 index 77f88a922..000000000 --- a/src/atoms/ContextMenu.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useAtom } from "jotai"; -import { atomWithReset, useResetAtom, useUpdateAtom } from "jotai/utils"; - -export type SelectedCell = { - rowIndex: number; - colIndex: number; -}; - -export type anchorEl = HTMLElement; - -const selectedCellAtom = atomWithReset(null); -const anchorEleAtom = atomWithReset(null); - -export function useSetAnchorEle() { - const setAnchorEle = useUpdateAtom(anchorEleAtom); - return { setAnchorEle }; -} - -export function useSetSelectedCell() { - const setSelectedCell = useUpdateAtom(selectedCellAtom); - return { setSelectedCell }; -} - -export function useContextMenuAtom() { - const [anchorEle] = useAtom(anchorEleAtom); - const [selectedCell] = useAtom(selectedCellAtom); - const resetAnchorEle = useResetAtom(anchorEleAtom); - const resetSelectedCell = useResetAtom(selectedCellAtom); - - const resetContextMenu = async () => { - await resetAnchorEle(); - await resetSelectedCell(); - }; - - return { - anchorEle, - selectedCell, - resetContextMenu, - }; -} diff --git a/src/atoms/RowyRunModal.ts b/src/atoms/RowyRunModal.ts deleted file mode 100644 index d62d74092..000000000 --- a/src/atoms/RowyRunModal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { atom, useAtom } from "jotai"; - -export const rowyRunModalAtom = atom({ open: false, feature: "", version: "" }); - -export const useRowyRunModal = () => { - const [, setOpen] = useAtom(rowyRunModalAtom); - - return (feature: string = "", version: string = "") => - setOpen({ open: true, feature, version }); -}; diff --git a/src/atoms/Table.ts b/src/atoms/Table.ts deleted file mode 100644 index 1c97ec873..000000000 --- a/src/atoms/Table.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { atomWithHash } from "jotai/utils"; - -export const modalAtom = atomWithHash< - "cloudLogs" | "extensions" | "webhooks" | "export" | "" ->("modal", ""); diff --git a/src/atoms/globalScope/auth.ts b/src/atoms/globalScope/auth.ts new file mode 100644 index 000000000..96d116868 --- /dev/null +++ b/src/atoms/globalScope/auth.ts @@ -0,0 +1,8 @@ +import { atom } from "jotai"; +import type { User } from "firebase/auth"; + +/** Currently signed in user. `undefined` means loading. */ +export const currentUserAtom = atom(undefined); + +/** User roles from Firebase Auth user custom claims */ +export const userRolesAtom = atom([]); diff --git a/src/atoms/globalScope/index.ts b/src/atoms/globalScope/index.ts new file mode 100644 index 000000000..aed968a43 --- /dev/null +++ b/src/atoms/globalScope/index.ts @@ -0,0 +1,9 @@ +/** Scope for atoms stored at the root of the app */ +export const globalScope = Symbol("globalScope"); + +export * from "./auth"; +export * from "./project"; +export * from "./user"; +export * from "./ui"; + +export * from "./rowyRun"; diff --git a/src/atoms/globalScope/project.ts b/src/atoms/globalScope/project.ts new file mode 100644 index 000000000..df8ecd86a --- /dev/null +++ b/src/atoms/globalScope/project.ts @@ -0,0 +1,94 @@ +import { atom } from "jotai"; +import { sortBy } from "lodash-es"; +import { ThemeOptions } from "@mui/material"; + +import { userRolesAtom } from "./auth"; +import { UserSettings } from "./user"; +import { + UpdateDocFunction, + UpdateCollectionFunction, + TableSettings, +} from "@src/types/table"; + +export const projectIdAtom = atom(""); + +/** Public settings are visible to unauthenticated users */ +export type PublicSettings = Partial<{ + signInOptions: Array< + | "google" + | "twitter" + | "facebook" + | "github" + | "microsoft" + | "apple" + | "yahoo" + | "email" + | "phone" + | "anonymous" + >; + theme: Record<"base" | "light" | "dark", ThemeOptions>; +}>; +/** Public settings are visible to unauthenticated users */ +export const publicSettingsAtom = atom({}); +/** Stores a function that updates public settings */ +export const updatePublicSettingsAtom = + atom | null>(null); + +/** Project settings are visible to authenticated users */ +export type ProjectSettings = Partial<{ + tables: TableSettings[]; + + setupCompleted: boolean; + + rowyRunUrl: string; + rowyRunRegion: string; + rowyRunBuildStatus: "BUILDING" | "COMPLETE"; + services: Partial<{ + hooks: string; + builder: string; + terminal: string; + }>; +}>; +/** Project settings are visible to authenticated users */ +export const projectSettingsAtom = atom({}); +/** Stores a function that updates project settings */ +export const updateProjectSettingsAtom = + atom | null>(null); + +/** Tables visible to the signed-in user based on roles */ +export const tablesAtom = atom((get) => { + const userRoles = get(userRolesAtom); + const tables = get(projectSettingsAtom).tables || []; + + return sortBy(tables, "name") + .filter( + (table) => + userRoles.includes("ADMIN") || + table.roles.some((role) => userRoles.includes(role)) + ) + .map((table) => ({ + ...table, + // Ensure id exists for backwards compatibility + id: table.id || table.collection, + // Ensure section exists + section: table.section ? table.section.trim() : "Other", + })); +}); + +/** Roles used in the project based on table settings */ +export const rolesAtom = atom((get) => + Array.from( + new Set( + get(tablesAtom).reduce( + (a, c) => [...a, ...c.roles], + ["ADMIN", "EDITOR", "VIEWER"] + ) + ) + ) +); + +/** User management page: all users */ +export const allUsersAtom = atom([]); +/** Stores a function that updates a user document */ +export const updateUserAtom = + atom | null>(null); diff --git a/src/atoms/globalScope/rowyRun.ts b/src/atoms/globalScope/rowyRun.ts new file mode 100644 index 000000000..e53d102e0 --- /dev/null +++ b/src/atoms/globalScope/rowyRun.ts @@ -0,0 +1,134 @@ +import { atom } from "jotai"; +import { selectAtom, atomWithStorage } from "jotai/utils"; +import { isEqual } from "lodash-es"; +import { getIdTokenResult } from "firebase/auth"; + +import { projectSettingsAtom } from "./project"; +import { currentUserAtom } from "./auth"; +import { RunRoute } from "@src/constants/runRoutes"; +import meta from "@root/package.json"; + +/** + * Get rowyRunUrl from projectSettings, but only update when this field changes */ +const rowyRunUrlAtom = selectAtom( + projectSettingsAtom, + (projectSettings) => projectSettings.rowyRunUrl +); +/** + * Get services from projectSettings, but only update when this field changes + */ +const rowyRunServicesAtom = selectAtom( + projectSettingsAtom, + (projectSettings) => projectSettings.services, + isEqual +); + +export interface IRowyRunRequestProps { + /** Optionally force refresh the token */ + forceRefresh?: boolean; + service?: "hooks" | "builder"; + /** Optionally use Rowy Run instance on localhost */ + localhost?: boolean; + + route: RunRoute; + body?: any; + /** Params appended to the URL. Will be transforme to a `/`-separated string. */ + params?: string[]; + /** Parse response as JSON. Default: true */ + json?: boolean; + /** Optionally pass an abort signal to abort the request */ + signal?: AbortSignal; + /** Optionally pass a callback that’s called if Rowy Run not set up */ + handleNotSetUp?: () => void; +} + +/** + * An atom that returns a function to call Rowy Run endpoints using the URL + * defined in project settings and retrieving a JWT token. + * + * Returns `false` if user not signed in or Rowy Run not set up. + * + * @example Basic usage: + * ``` + * const [rowyRun] = useAtom(rowyRunAtom, globalScope); + * ... + * await rowyRun(...); + * ``` + */ +export const rowyRunAtom = atom((get) => { + const rowyRunUrl = get(rowyRunUrlAtom); + const rowyRunServices = get(rowyRunServicesAtom); + const currentUser = get(currentUserAtom); + + return async ({ + forceRefresh, + localhost = false, + service, + route, + params, + body, + signal, + json = true, + handleNotSetUp, + }: IRowyRunRequestProps): Promise => { + if (!currentUser) { + console.log("Rowy Run: Not signed in", route.path); + if (handleNotSetUp) handleNotSetUp(); + return false; + } + const authToken = await getIdTokenResult(currentUser!, forceRefresh); + + const serviceUrl = localhost + ? "http://localhost:8080" + : service + ? rowyRunServices?.[service] + : rowyRunUrl; + if (!serviceUrl) { + console.log("Rowy Run: Not set up", route.path); + if (handleNotSetUp) handleNotSetUp(); + return false; + } + + const { method, path } = route; + let url = serviceUrl + path; + if (params && params.length > 0) url = url + "/" + params.join("/"); + + const response = await fetch(url, { + method: method, + mode: "cors", + cache: "no-cache", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + authToken, + }, + redirect: "follow", + referrerPolicy: "no-referrer", + // body data type must match "Content-Type" header + body: body && method !== "GET" ? JSON.stringify(body) : null, + signal, + }); + + if (json) return await response.json(); + return response; + }; +}); + +type RowyRunLatestUpdate = { + lastChecked: string; + rowy: null | Record; + rowyRun: null | Record; + deployedRowy: string; + deployedRowyRun: string; +}; +/** Store latest update from GitHub releases and currently deployed versions */ +export const rowyRunLatestUpdateAtom = atomWithStorage( + "__ROWY__UPDATE_CHECK", + { + lastChecked: "", + rowy: null, + rowyRun: null, + deployedRowy: meta.version, + deployedRowyRun: "", + } +); diff --git a/src/atoms/globalScope/ui.ts b/src/atoms/globalScope/ui.ts new file mode 100644 index 000000000..1725a6961 --- /dev/null +++ b/src/atoms/globalScope/ui.ts @@ -0,0 +1,126 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +import { DialogProps, ButtonProps } from "@mui/material"; +import { TableSettings } from "@src/types/table"; + +/** Nav open state stored in local storage. */ +export const navOpenAtom = atomWithStorage("__ROWY__NAV_OPEN", false); +/** Nav pinned state stored in local storage. */ +export const navPinnedAtom = atomWithStorage("__ROWY__NAV_PINNED", false); + +/** View for tables page */ +export const tablesViewAtom = atomWithStorage<"grid" | "list">( + "__ROWY__HOME_VIEW", + "grid" +); + +export type ConfirmDialogProps = { + open: boolean; + + title?: string; + /** Pass a string to display basic styled text */ + body?: React.ReactNode; + + /** Callback called when user clicks confirm */ + handleConfirm?: () => void; + /** Optionally override confirm button text */ + confirm?: string | JSX.Element; + /** Optionally require user to type this string to enable the confirm button */ + confirmationCommand?: string; + /** Optionally set confirm button color */ + confirmColor?: ButtonProps["color"]; + + /** Callback called when user clicks cancel */ + handleCancel?: () => void; + /** Optionally override cancel button text */ + cancel?: string; + /** Optionally hide cancel button */ + hideCancel?: boolean; + + /** Optionally set dialog max width */ + maxWidth?: DialogProps["maxWidth"]; +}; +/** + * Open a confirm dialog + * + * @example Basic usage: + * ``` + * const confirm = useSetAtom(confirmDialogAtom, globalScope); + * confirm({ handleConfirm: () => ... }); + * ``` + */ +export const confirmDialogAtom = atom( + { open: false } as ConfirmDialogProps, + (get, set, update: Partial) => { + set(confirmDialogAtom, { + ...get(confirmDialogAtom), + open: true, // Don’t require this to be set explicitly + ...update, + }); + } +); + +export type RowyRunModalState = { + open: boolean; + feature: string; + version: string; +}; +/** + * Open global Rowy Run modal if feature not available. + * Calling the set function resets props. + * + * @example Basic usage: + * ``` + * const openRowyRunModal = useSetAtom(rowyRunModalAtom, globalScope); + * openRowyRunModal({ feature: ... , version: ... }); + * ``` + * + * @example Close dialog: + * ``` + * openRowyRunModal({ open: false }) + * ``` + */ +export const rowyRunModalAtom = atom( + { open: false, feature: "", version: "" } as RowyRunModalState, + (_, set, update?: Partial) => { + set(rowyRunModalAtom, { + open: true, + feature: "", + version: "", + ...update, + }); + } +); + +export type TableSettingsDialogState = { + open: boolean; + mode: "create" | "update"; + data: TableSettings | null; +}; +/** + * Open table settings dialog. + * Calling the set function resets props. + * + * @example Basic usage: + * ``` + * const openTableSettingsDialog = useSetAtom(tableSettingsDialogAtom, globalScope); + * openTableSettingsDialog({ data: ... }); + * ``` + * + * @example Clear dialog: + * ``` + * openTableSettingsDialog({ open: false }) + * ``` + */ +export const tableSettingsDialogAtom = atom( + { open: false, mode: "create", data: null } as TableSettingsDialogState, + (_, set, update?: Partial) => { + set(tableSettingsDialogAtom, { + open: true, + mode: "create", + data: null, + ...update, + }); + } +); diff --git a/src/atoms/globalScope/user.ts b/src/atoms/globalScope/user.ts new file mode 100644 index 000000000..f4144c275 --- /dev/null +++ b/src/atoms/globalScope/user.ts @@ -0,0 +1,80 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { merge } from "lodash-es"; +import { ThemeOptions } from "@mui/material"; + +import themes from "@src/theme"; +import { publicSettingsAtom } from "./project"; +import { UpdateDocFunction, TableFilter } from "@src/types/table"; + +/** User info and settings */ +export type UserSettings = Partial<{ + _rowy_id: string; + /** Synced from user auth info */ + user: { + email: string; + displayName?: string; + photoURL?: string; + phoneNumber?: string; + }; + roles: string[]; + + theme: Record<"base" | "light" | "dark", ThemeOptions>; + + favoriteTables: string[]; + /** Stores user overrides */ + tables: Record< + string, + Partial<{ + filters: TableFilter[]; + hiddenFields: string[]; + }> + >; +}>; +/** User info and settings */ +export const userSettingsAtom = atom({}); +/** Stores a function that updates user settings */ +export const updateUserSettingsAtom = + atom | null>(null); + +/** + * Stores which theme is currently active, based on user or OS setting. + * Saved in localStorage. + */ +export const themeAtom = atomWithStorage<"light" | "dark">( + "__ROWY__THEME", + "light" +); +/** + * User can override OS theme. Saved in localStorage. + */ +export const themeOverriddenAtom = atomWithStorage( + "__ROWY__THEME_OVERRIDDEN", + false +); + +/** Customized base theme based on project and user settings */ +export const customizedThemesAtom = atom((get) => { + const publicSettings = get(publicSettingsAtom); + const userSettings = get(userSettingsAtom); + + const lightCustomizations = merge( + {}, + publicSettings.theme?.base, + publicSettings.theme?.light, + userSettings.theme?.base, + userSettings.theme?.light + ); + const darkCustomizations = merge( + {}, + publicSettings.theme?.base, + publicSettings.theme?.dark, + userSettings.theme?.base, + userSettings.theme?.dark + ); + + return { + light: themes.light(lightCustomizations), + dark: themes.dark(darkCustomizations), + }; +}); diff --git a/src/atoms/tableScope/index.ts b/src/atoms/tableScope/index.ts new file mode 100644 index 000000000..e5e34cac4 --- /dev/null +++ b/src/atoms/tableScope/index.ts @@ -0,0 +1,4 @@ +/** Scope for atoms stored at the table level */ +export const tableScope = Symbol("tableScope"); + +export * from "./table"; diff --git a/src/atoms/tableScope/table.ts b/src/atoms/tableScope/table.ts new file mode 100644 index 000000000..6a5af0930 --- /dev/null +++ b/src/atoms/tableScope/table.ts @@ -0,0 +1,18 @@ +import { atom } from "jotai"; +import { + TableSettings, + TableSchema, + TableFilter, + TableOrder, +} from "@src/types/table"; + +export const tableIdAtom = atom(undefined); +export const tableSettingsAtom = atom(undefined); +export const tableSchemaAtom = atom(undefined); + +export const tableFiltersAtom = atom([]); +export const tableOrdersAtom = atom([]); +export const tablePageAtom = atom(0); + +export const tableRowsAtom = atom[]>([]); +export const tableLoadingMoreAtom = atom(false); diff --git a/src/components/AccessDenied.tsx b/src/components/AccessDenied.tsx new file mode 100644 index 000000000..00de18d08 --- /dev/null +++ b/src/components/AccessDenied.tsx @@ -0,0 +1,93 @@ +import { useAtom } from "jotai"; + +import { + Typography, + Stack, + Avatar, + Alert, + Divider, + Link as MuiLink, + Button, +} from "@mui/material"; +import SecurityIcon from "@mui/icons-material/SecurityOutlined"; + +import EmptyState from "@src/components/EmptyState"; + +import { + globalScope, + currentUserAtom, + userRolesAtom, +} from "@src/atoms/globalScope"; +import { WIKI_LINKS } from "@src/constants/externalLinks"; +import { ROUTES } from "@src/constants/routes"; + +export default function AccessDenied() { + const [currentUser] = useAtom(currentUserAtom, globalScope); + const [userRoles] = useAtom(userRolesAtom, globalScope); + + if (!currentUser) window.location.reload(); + + return ( + +
+ + +
+ {currentUser?.displayName} + {currentUser?.email} +
+
+ + {(!userRoles || userRoles.length === 0) && ( + + Your account has no roles set + + )} +
+ + + You do not have access to this project. Please contact the project + owner. + + + + + + OR + + + + If you are the project owner, please follow{" "} + + these instructions + {" "} + to set up this project’s security rules. + + + } + sx={{ + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + bgcolor: "background.default", + zIndex: 9999, + }} + /> + ); +} diff --git a/src/components/Auth/FirebaseUi.tsx b/src/components/Auth/FirebaseUi.tsx deleted file mode 100644 index 2870fc322..000000000 --- a/src/components/Auth/FirebaseUi.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { useState, useEffect } from "react"; -import clsx from "clsx"; - -import StyledFirebaseAuth from "react-firebaseui/StyledFirebaseAuth"; -import { Props as FirebaseUiProps } from "react-firebaseui"; - -import { makeStyles, createStyles } from "@mui/styles"; -import { Typography } from "@mui/material"; -import { alpha } from "@mui/material/styles"; -import Skeleton from "@mui/material/Skeleton"; - -import { auth, db } from "@src/firebase"; -import { defaultUiConfig, getSignInOptions } from "@src/firebase/firebaseui"; -import { PUBLIC_SETTINGS } from "@src/config/dbPaths"; - -const useStyles = makeStyles((theme) => - createStyles({ - "@global": { - ".rowy-firebaseui": { - width: "100%", - minHeight: 32, - - "& .firebaseui-container": { - backgroundColor: "transparent", - color: theme.palette.text.primary, - fontFamily: theme.typography.fontFamily, - }, - "& .firebaseui-text": { - color: theme.palette.text.secondary, - fontFamily: theme.typography.fontFamily, - }, - "& .firebaseui-tos": { - ...theme.typography.caption, - color: theme.palette.text.disabled, - }, - "& .firebaseui-country-selector": { - color: theme.palette.text.primary, - }, - "& .firebaseui-title": { - ...theme.typography.h5, - color: theme.palette.text.primary, - }, - "& .firebaseui-subtitle": { - ...theme.typography.h6, - color: theme.palette.text.secondary, - }, - "& .firebaseui-error": { - ...theme.typography.caption, - color: theme.palette.error.main, - }, - - "& .firebaseui-card-content, & .firebaseui-card-footer": { padding: 0 }, - "& .firebaseui-idp-list, & .firebaseui-tenant-list": { margin: 0 }, - "& .firebaseui-idp-list>.firebaseui-list-item, & .firebaseui-tenant-list>.firebaseui-list-item": - { - margin: 0, - }, - "& .firebaseui-list-item + .firebaseui-list-item": { - paddingTop: theme.spacing(1), - }, - - "& .mdl-button": { - borderRadius: theme.shape.borderRadius, - ...theme.typography.button, - }, - "& .mdl-button--raised": { - boxShadow: `0 -1px 0 0 rgba(0, 0, 0, 0.12) inset, ${theme.shadows[2]}`, - "&:hover": { - boxShadow: `0 -1px 0 0 rgba(0, 0, 0, 0.12) inset, ${theme.shadows[4]}`, - }, - "&:active, &:focus": { - boxShadow: `0 -1px 0 0 rgba(0, 0, 0, 0.12) inset, ${theme.shadows[8]}`, - }, - }, - "& .mdl-card": { - boxShadow: "none", - minHeight: 0, - }, - "& .mdl-button--primary.mdl-button--primary": { - color: theme.palette.primary.main, - }, - "& .mdl-button--raised.mdl-button--colored": { - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastText, - - "&:active, &:focus:not(:active), &:hover": { - backgroundColor: theme.palette.primary.main, - }, - }, - - "& .firebaseui-idp-button.mdl-button--raised, & .firebaseui-tenant-button.mdl-button--raised": - { - maxWidth: "none", - minHeight: 32, - padding: theme.spacing(0.5, 1), - - backgroundColor: theme.palette.action.input + " !important", - "&:hover": { - backgroundColor: theme.palette.action.hover + " !important", - }, - "&:active, &:focus": { - backgroundColor: - theme.palette.action.disabledBackground + " !important", - }, - - "&, &:hover, &.Mui-disabled": { border: "none" }, - "&, &:hover, &:active, &:focus": { - boxShadow: `0 0 0 1px ${theme.palette.action.inputOutline} inset, - 0 ${theme.palette.mode === "dark" ? "" : "-"}1px 0 0 ${ - theme.palette.action.inputOutline - } inset`, - }, - }, - "& .firebaseui-idp-icon": { - display: "block", - width: 20, - height: 20, - }, - "& .firebaseui-idp-text": { - ...theme.typography.button, - color: theme.palette.text.primary, - - paddingLeft: theme.spacing(2), - paddingRight: Number(theme.spacing(2).replace("px", "")) + 18, - marginLeft: -18, - width: "100%", - textAlign: "center", - - "&.firebaseui-idp-text-long": { display: "none" }, - "&.firebaseui-idp-text-short": { display: "table-cell" }, - }, - - "& .firebaseui-idp-google > .firebaseui-idp-text": { - color: theme.palette.text.primary, - }, - "& .firebaseui-idp-github .firebaseui-idp-icon, & [data-provider-id='apple.com'] .firebaseui-idp-icon": - { - filter: theme.palette.mode === "dark" ? "invert(1)" : "", - }, - "& [data-provider-id='microsoft.com'] .firebaseui-idp-icon": { - width: 21, - height: 21, - position: "relative", - left: -1, - top: -1, - }, - "& [data-provider-id='yahoo.com'] > .firebaseui-idp-icon-wrapper > .firebaseui-idp-icon": - { - width: 18, - height: 18, - filter: - theme.palette.mode === "dark" - ? "invert(1) saturate(0) brightness(1.5)" - : "", - }, - "& .firebaseui-idp-password .firebaseui-idp-icon, & .firebaseui-idp-phone .firebaseui-idp-icon, & .firebaseui-idp-anonymous .firebaseui-idp-icon": - { - width: 24, - height: 24, - position: "relative", - left: -2, - filter: theme.palette.mode === "light" ? "invert(1)" : "", - }, - - "& .firebaseui-card-header": { padding: 0 }, - "& .firebaseui-card-actions": { padding: 0 }, - - "& .firebaseui-input, & .firebaseui-input-invalid": { - ...theme.typography.body1, - color: theme.palette.text.primary, - }, - "& .firebaseui-textfield.mdl-textfield .firebaseui-input": { - borderColor: theme.palette.divider, - }, - "& .mdl-textfield.is-invalid .mdl-textfield__input": { - borderColor: theme.palette.error.main, - }, - "& .firebaseui-label": { - ...theme.typography.subtitle2, - color: theme.palette.text.secondary, - }, - "& .mdl-textfield--floating-label.is-dirty .mdl-textfield__label, .mdl-textfield--floating-label.is-focused .mdl-textfield__label": - { - color: theme.palette.text.primary, - }, - "& .firebaseui-textfield.mdl-textfield .firebaseui-label:after": { - backgroundColor: theme.palette.primary.main, - }, - "& .mdl-textfield.is-invalid .mdl-textfield__label:after": { - backgroundColor: theme.palette.error.main, - }, - - "& .mdl-progress>.bufferbar": { - background: alpha(theme.palette.primary.main, 0.33), - }, - "& .mdl-progress>.progressbar": { - backgroundColor: theme.palette.primary.main + " !important", - }, - }, - }, - - signInText: { - display: "block", - textAlign: "center", - color: theme.palette.text.secondary, - margin: theme.spacing(-1, 0, -3), - }, - - skeleton: { - width: "100%", - marginBottom: "calc(var(--spacing-contents) * -1)", - - "& > *": { - width: "100%", - height: 32, - borderRadius: theme.shape.borderRadius, - }, - - "& > * + *": { - marginTop: theme.spacing(1), - }, - }, - }) -); - -export default function FirebaseUi(props: Partial) { - const classes = useStyles(); - - const [signInOptions, setSignInOptions] = useState< - Parameters[0] | undefined - >(); - useEffect(() => { - db.doc(PUBLIC_SETTINGS) - .get() - .then((doc) => { - const options = doc?.get("signInOptions"); - if (!options) { - setSignInOptions(["google"]); - } else { - setSignInOptions(options); - } - }) - .catch(() => setSignInOptions(["google"])); - }, []); - - if (!signInOptions) - return ( - <> - - Continue with - - -
- -
- - ); - - const uiConfig: firebaseui.auth.Config = { - ...defaultUiConfig, - ...props.uiConfig, - callbacks: { - uiShown: () => { - const node = document.getElementById("rowy-firebaseui-skeleton"); - if (node) node.style.display = "none"; - }, - ...props.uiConfig?.callbacks, - }, - signInOptions: getSignInOptions(signInOptions), - }; - - return ( - <> - - Continue with - - - - - ); -} diff --git a/src/components/ButtonWithStatus.tsx b/src/components/ButtonWithStatus.tsx deleted file mode 100644 index 581e62e41..000000000 --- a/src/components/ButtonWithStatus.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from "react"; -import clsx from "clsx"; - -import { makeStyles, createStyles } from "@mui/styles"; -import { Button, ButtonProps } from "@mui/material"; -import { alpha } from "@mui/material/styles"; - -export const useStyles = makeStyles((theme) => - createStyles({ - root: { - position: "relative", - zIndex: 1, - }, - - active: { - color: - theme.palette.mode === "dark" - ? theme.palette.primary.light - : theme.palette.primary.dark, - backgroundColor: alpha( - theme.palette.primary.main, - theme.palette.action.selectedOpacity - ), - borderColor: theme.palette.primary.main, - - "&:hover": { - color: - theme.palette.mode === "dark" - ? theme.palette.primary.light - : theme.palette.primary.dark, - backgroundColor: alpha( - theme.palette.mode === "dark" - ? theme.palette.primary.light - : theme.palette.primary.dark, - theme.palette.action.selectedOpacity + - theme.palette.action.hoverOpacity - ), - borderColor: "currentColor", - }, - }, - }) -); - -export interface IButtonWithStatusProps extends ButtonProps { - active?: boolean; -} - -export const ButtonWithStatus = React.forwardRef(function ButtonWithStatus_( - { active = false, className, ...props }: IButtonWithStatusProps, - ref: React.Ref -) { - const classes = useStyles(); - - return ( - )} diff --git a/src/components/Confirmation.tsx b/src/components/Confirmation.tsx deleted file mode 100644 index 9702452bd..000000000 --- a/src/components/Confirmation.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React, { useState } from "react"; - -import { makeStyles, createStyles } from "@mui/styles"; -import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogContentText from "@mui/material/DialogContentText"; -import DialogTitle from "@mui/material/DialogTitle"; -import TextField from "@mui/material/TextField"; -import { SlideTransitionMui } from "@src/components/Modal/SlideTransition"; - -const useStyles = makeStyles(() => - createStyles({ - root: { - display: "flex", - flexWrap: "wrap", - }, - dryWrapper: {}, - dryField: {}, - }) -); - -export interface IConfirmationProps { - children: JSX.Element; - message?: { - title?: string; - customBody?: string; - body?: string | React.ReactNode; - cancel?: string; - confirm?: string | JSX.Element; - color?: "error"; - }; - confirmationCommand?: string; - functionName?: string; - stopPropagation?: boolean; -} - -export default function Confirmation({ - children, - message, - confirmationCommand, - functionName = "onClick", - stopPropagation = false, -}: IConfirmationProps) { - const classes = useStyles(); - const [showDialog, setShowDialog] = useState(false); - const [dryText, setDryText] = useState(""); - - const handleClose = () => { - setShowDialog(false); - }; - - const confirmHandler = children.props[functionName]; - const button = React.cloneElement(children, { - [functionName]: (e) => { - if (stopPropagation && e && e.stopPropagation) e.stopPropagation(); - setShowDialog(true); - }, - }); - - return ( - <> - {button} - - - - {(message && message.title) || "Are you sure?"} - - {message && ( - - {message.customBody} - {message.body && - (typeof message.body === "string" ? ( - {message.body} - ) : ( - message.body - ))} - {confirmationCommand && ( -
- - Type {confirmationCommand} below to continue: - - { - setDryText(e.target.value); - }} - className={classes.dryField} - InputProps={{ disableUnderline: true }} - autoFocus - margin="dense" - label={confirmationCommand} - fullWidth - /> -
- )} -
- )} - - - - -
- - ); -} diff --git a/src/components/ConfirmationDialog/Context.ts b/src/components/ConfirmationDialog/Context.ts deleted file mode 100644 index d66daa83b..000000000 --- a/src/components/ConfirmationDialog/Context.ts +++ /dev/null @@ -1,8 +0,0 @@ -import React, { useContext } from "react"; -import { IConfirmation, CONFIRMATION_EMPTY_STATE } from "./props"; -const ConfirmationContext = React.createContext( - CONFIRMATION_EMPTY_STATE -); -export default ConfirmationContext; - -export const useConfirmation = () => useContext(ConfirmationContext); diff --git a/src/components/ConfirmationDialog/Provider.tsx b/src/components/ConfirmationDialog/Provider.tsx deleted file mode 100644 index 807866063..000000000 --- a/src/components/ConfirmationDialog/Provider.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, { useState } from "react"; - -import { confirmationProps } from "./props"; -import Dialog from "./Dialog"; -import ConfirmationContext from "./Context"; -interface IConfirmationProviderProps { - children: React.ReactNode; -} - -const ConfirmationProvider: React.FC = ({ - children, -}) => { - const [state, setState] = useState(); - const [open, setOpen] = useState(false); - const handleClose = () => { - setOpen(false); - setTimeout(() => setState(undefined), 300); - }; - const requestConfirmation = (props: confirmationProps) => { - setState(props); - setOpen(true); - }; - return ( - - {children} - - - - ); -}; - -export default ConfirmationProvider; diff --git a/src/components/ConfirmationDialog/index.ts b/src/components/ConfirmationDialog/index.ts deleted file mode 100644 index f2b000023..000000000 --- a/src/components/ConfirmationDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useConfirmation } from "./Context"; diff --git a/src/components/ConfirmationDialog/props.ts b/src/components/ConfirmationDialog/props.ts deleted file mode 100644 index e9115f254..000000000 --- a/src/components/ConfirmationDialog/props.ts +++ /dev/null @@ -1,27 +0,0 @@ -export type confirmationProps = - | { - title?: string; - customBody?: React.ReactNode; - body?: string; - cancel?: string; - hideCancel?: boolean; - confirm?: string | JSX.Element; - confirmationCommand?: string; - handleConfirm: () => void; - handleCancel?: () => void; - open?: Boolean; - confirmColor?: string; - } - | undefined; -export interface IConfirmation { - dialogProps?: confirmationProps; - handleClose: () => void; - open: boolean; - requestConfirmation: (props: confirmationProps) => void; -} -export const CONFIRMATION_EMPTY_STATE = { - dialogProps: undefined, - open: false, - handleClose: () => {}, - requestConfirmation: () => {}, -}; diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx deleted file mode 100644 index 53da9f0cb..000000000 --- a/src/components/ErrorBoundary.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Component } from "react"; -import EmptyState, { IEmptyStateProps } from "./EmptyState"; - -import { Button } from "@mui/material"; -import ReloadIcon from "@mui/icons-material/Refresh"; -import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; - -import meta from "@root/package.json"; - -class ErrorBoundary extends Component< - IEmptyStateProps & { render?: (errorMessage: string) => React.ReactNode } -> { - state = { hasError: false, errorMessage: "" }; - - static getDerivedStateFromError(error: Error) { - // Update state so the next render will show the fallback UI. - return { hasError: true, errorMessage: error.message }; - } - - componentDidCatch(error: Error, errorInfo: object) { - console.log(error, errorInfo); - // You can also log the error to an error reporting service - //logErrorToMyService(error, errorInfo); - } - - render() { - if (this.state.hasError) { - if (this.props.render) return this.props.render(this.state.errorMessage); - - if (this.state.errorMessage.startsWith("Loading chunk")) - return ( - - Reload this page to get the latest update - - - } - fullScreen - /> - ); - - return ( - - {this.state.errorMessage} - - - } - fullScreen - {...this.props} - /> - ); - } - - return this.props.children; - } -} - -export default ErrorBoundary; diff --git a/src/components/ErrorFallback.tsx b/src/components/ErrorFallback.tsx new file mode 100644 index 000000000..e800aa3e7 --- /dev/null +++ b/src/components/ErrorFallback.tsx @@ -0,0 +1,67 @@ +import { FallbackProps } from "react-error-boundary"; + +import { Button } from "@mui/material"; +import ReloadIcon from "@mui/icons-material/Refresh"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; + +import EmptyState, { IEmptyStateProps } from "@src/components/EmptyState"; +import AccessDenied from "@src/components/AccessDenied"; +import meta from "@root/package.json"; + +export interface IErrorFallbackProps extends FallbackProps, IEmptyStateProps {} + +export default function ErrorFallback({ + error, + resetErrorBoundary, + ...props +}: IErrorFallbackProps) { + if (error.message.startsWith("Loading chunk")) + return ( + + Reload this page to get the latest update + + + } + fullScreen + /> + ); + + if ((error as any).code === "permission-denied") return ; + + return ( + + + {(error as any).code && {(error as any).code}: } + {error.message} + + + + } + fullScreen + {...props} + /> + ); +} diff --git a/src/components/FirebaseUi.tsx b/src/components/FirebaseUi.tsx new file mode 100644 index 000000000..ceda7035f --- /dev/null +++ b/src/components/FirebaseUi.tsx @@ -0,0 +1,272 @@ +import { useMemo, useEffect } from "react"; +import { useAtom } from "jotai"; + +import * as firebaseui from "firebaseui"; +import "firebaseui/dist/firebaseui.css"; +import { onAuthStateChanged } from "firebase/auth"; + +import { makeStyles } from "tss-react/mui"; +import { Typography } from "@mui/material"; +import { alpha } from "@mui/material/styles"; + +import { globalScope, publicSettingsAtom } from "@src/atoms/globalScope"; +import { firebaseAuthAtom } from "@src/sources/ProjectSourceFirebase"; +import { defaultUiConfig, getSignInOptions } from "@src/config/firebaseui"; + +const ELEMENT_ID = "firebaseui_container"; + +const useStyles = makeStyles()((theme) => ({ + root: { + width: "100%", + minHeight: 32, + + "& .firebaseui-container": { + backgroundColor: "transparent", + color: theme.palette.text.primary, + fontFamily: theme.typography.fontFamily, + }, + "& .firebaseui-text": { + color: theme.palette.text.secondary, + fontFamily: theme.typography.fontFamily, + }, + "& .firebaseui-tos": { + ...(theme.typography.caption as any), + color: theme.palette.text.disabled, + }, + "& .firebaseui-country-selector": { + color: theme.palette.text.primary, + }, + "& .firebaseui-title": { + ...(theme.typography.h5 as any), + color: theme.palette.text.primary, + }, + "& .firebaseui-subtitle": { + ...(theme.typography.h6 as any), + color: theme.palette.text.secondary, + }, + "& .firebaseui-error": { + ...(theme.typography.caption as any), + color: theme.palette.error.main, + }, + + "& .firebaseui-card-content, & .firebaseui-card-footer": { padding: 0 }, + "& .firebaseui-idp-list, & .firebaseui-tenant-list": { margin: 0 }, + "& .firebaseui-idp-list>.firebaseui-list-item, & .firebaseui-tenant-list>.firebaseui-list-item": + { + margin: 0, + }, + "& .firebaseui-list-item + .firebaseui-list-item": { + paddingTop: theme.spacing(1), + }, + + "& .mdl-button": { + borderRadius: theme.shape.borderRadius, + ...(theme.typography.button as any), + }, + "& .mdl-button--raised": { + boxShadow: `0 -1px 0 0 rgba(0, 0, 0, 0.12) inset, ${theme.shadows[2]}`, + "&:hover": { + boxShadow: `0 -1px 0 0 rgba(0, 0, 0, 0.12) inset, ${theme.shadows[4]}`, + }, + "&:active, &:focus": { + boxShadow: `0 -1px 0 0 rgba(0, 0, 0, 0.12) inset, ${theme.shadows[8]}`, + }, + }, + "& .mdl-card": { + boxShadow: "none", + minHeight: 0, + }, + "& .mdl-button--primary.mdl-button--primary": { + color: theme.palette.primary.main, + }, + "& .mdl-button--raised.mdl-button--colored": { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + + "&:active, &:focus:not(:active), &:hover": { + backgroundColor: theme.palette.primary.main, + }, + }, + + "& .firebaseui-idp-button.mdl-button--raised, & .firebaseui-tenant-button.mdl-button--raised": + { + maxWidth: "none", + minHeight: 32, + padding: theme.spacing(0.5, 1), + + backgroundColor: theme.palette.action.input + " !important", + "&:hover": { + backgroundColor: theme.palette.action.hover + " !important", + }, + "&:active, &:focus": { + backgroundColor: + theme.palette.action.disabledBackground + " !important", + }, + + "&, &:hover, &.Mui-disabled": { border: "none" }, + "&, &:hover, &:active, &:focus": { + boxShadow: `0 0 0 1px ${theme.palette.action.inputOutline} inset, + 0 ${theme.palette.mode === "dark" ? "" : "-"}1px 0 0 ${ + theme.palette.action.inputOutline + } inset`, + }, + }, + "& .firebaseui-idp-icon": { + display: "block", + width: 20, + height: 20, + }, + "& .firebaseui-idp-text": { + ...(theme.typography.button as any), + color: theme.palette.text.primary, + + paddingLeft: theme.spacing(2), + paddingRight: Number(theme.spacing(2).replace("px", "")) + 18, + marginLeft: -18, + width: "100%", + textAlign: "center", + + "&.firebaseui-idp-text-long": { display: "none" }, + "&.firebaseui-idp-text-short": { display: "table-cell" }, + }, + + "& .firebaseui-idp-google > .firebaseui-idp-text": { + color: theme.palette.text.primary, + }, + "& .firebaseui-idp-github .firebaseui-idp-icon, & [data-provider-id='apple.com'] .firebaseui-idp-icon": + { + filter: theme.palette.mode === "dark" ? "invert(1)" : "", + }, + "& [data-provider-id='microsoft.com'] .firebaseui-idp-icon": { + width: 21, + height: 21, + position: "relative", + left: -1, + top: -1, + }, + "& [data-provider-id='yahoo.com'] > .firebaseui-idp-icon-wrapper > .firebaseui-idp-icon": + { + width: 18, + height: 18, + filter: + theme.palette.mode === "dark" + ? "invert(1) saturate(0) brightness(1.5)" + : "", + }, + "& .firebaseui-idp-password .firebaseui-idp-icon, & .firebaseui-idp-phone .firebaseui-idp-icon, & .firebaseui-idp-anonymous .firebaseui-idp-icon": + { + width: 24, + height: 24, + position: "relative", + left: -2, + filter: theme.palette.mode === "light" ? "invert(1)" : "", + }, + + "& .firebaseui-card-header": { padding: 0 }, + "& .firebaseui-card-actions": { padding: 0 }, + + "& .firebaseui-input, & .firebaseui-input-invalid": { + ...(theme.typography.body1 as any), + color: theme.palette.text.primary, + }, + "& .firebaseui-textfield.mdl-textfield .firebaseui-input": { + borderColor: theme.palette.divider, + }, + "& .mdl-textfield.is-invalid .mdl-textfield__input": { + borderColor: theme.palette.error.main, + }, + "& .firebaseui-label": { + ...(theme.typography.subtitle2 as any), + color: theme.palette.text.secondary, + }, + "& .mdl-textfield--floating-label.is-dirty .mdl-textfield__label, .mdl-textfield--floating-label.is-focused .mdl-textfield__label": + { + color: theme.palette.text.primary, + }, + "& .firebaseui-textfield.mdl-textfield .firebaseui-label:after": { + backgroundColor: theme.palette.primary.main, + }, + "& .mdl-textfield.is-invalid .mdl-textfield__label:after": { + backgroundColor: theme.palette.error.main, + }, + + "& .mdl-progress>.bufferbar": { + background: alpha(theme.palette.primary.main, 0.33), + }, + "& .mdl-progress>.progressbar": { + backgroundColor: theme.palette.primary.main + " !important", + }, + }, +})); + +export interface IFirebaseUiProps { + className?: string; + uiConfig?: firebaseui.auth.Config; +} + +export default function FirebaseUi(props: IFirebaseUiProps) { + const { classes, cx } = useStyles(); + const [firebaseAuth] = useAtom(firebaseAuthAtom, globalScope); + const [publicSettings] = useAtom(publicSettingsAtom, globalScope); + + const signInOptions: typeof publicSettings.signInOptions = useMemo( + () => + Array.isArray(publicSettings.signInOptions) && + publicSettings.signInOptions.length > 0 + ? publicSettings.signInOptions + : ["google"], + [publicSettings.signInOptions] + ); + + const uiConfig: firebaseui.auth.Config = useMemo( + () => ({ + ...defaultUiConfig, + ...props.uiConfig, + signInOptions: getSignInOptions(signInOptions), + }), + [props.uiConfig, signInOptions] + ); + + useEffect(() => { + let firebaseUiWidget: firebaseui.auth.AuthUI; + let userSignedIn = false; + let unregisterAuthObserver: ReturnType; + + // Get or Create a firebaseUI instance. + firebaseUiWidget = + firebaseui.auth.AuthUI.getInstance() || + new firebaseui.auth.AuthUI(firebaseAuth); + + if (uiConfig.signInFlow === "popup") firebaseUiWidget.reset(); + + // We track the auth state to reset firebaseUi if the user signs out. + unregisterAuthObserver = onAuthStateChanged(firebaseAuth, (user) => { + if (!user && userSignedIn) firebaseUiWidget.reset(); + userSignedIn = !!user; + }); + + // Render the firebaseUi Widget. + firebaseUiWidget.start("#" + ELEMENT_ID, uiConfig); + + return () => { + unregisterAuthObserver(); + firebaseUiWidget.reset(); + }; + }, [firebaseAuth, uiConfig]); + + return ( + <> + + Continue with + + +
+ + ); +} diff --git a/src/components/FloatingSearch.tsx b/src/components/FloatingSearch.tsx index 83ddf6361..3068323f1 100644 --- a/src/components/FloatingSearch.tsx +++ b/src/components/FloatingSearch.tsx @@ -8,7 +8,7 @@ import { import SearchIcon from "@mui/icons-material/Search"; import SlideTransition from "@src/components/Modal/SlideTransition"; -import { APP_BAR_HEIGHT } from "@src/components/Navigation"; +import { APP_BAR_HEIGHT } from "@src/layouts/Navigation"; export interface IFloatingSearchProps extends Partial { label: string; @@ -120,6 +120,12 @@ export default function FloatingSearch({ }px)`, left: (theme) => (theme.shape.borderRadius as number) * 2, }, + + "&.Mui-disabled": { + bgcolor: "transparent", + boxShadow: "none", + "& .MuiInputAdornment-root": { color: "text.disabled" }, + }, }, }} {...props} diff --git a/src/components/FormattedChip.tsx b/src/components/FormattedChip.tsx deleted file mode 100644 index 809fe1216..000000000 --- a/src/components/FormattedChip.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Chip, ChipProps } from "@mui/material"; - -export const VARIANTS = ["yes", "no", "maybe"]; -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)) { - return ; - } - - return ; -} diff --git a/src/components/HelperText.tsx b/src/components/HelperText.tsx deleted file mode 100644 index 714e02557..000000000 --- a/src/components/HelperText.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useTheme } from "@mui/material"; - -export interface IHelperTextProps { - children: React.ReactNode; -} - -export default function HelperText(props: IHelperTextProps) { - const theme = useTheme(); - - return ( -
- ); -} diff --git a/src/components/Home/AccessDenied.tsx b/src/components/Home/AccessDenied.tsx deleted file mode 100644 index a718cbbd2..000000000 --- a/src/components/Home/AccessDenied.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Link } from "react-router-dom"; - -import { Typography, Link as MuiLink, Button } from "@mui/material"; -import SecurityIcon from "@mui/icons-material/SecurityOutlined"; - -import EmptyState from "@src/components/EmptyState"; - -import { WIKI_LINKS } from "@src/constants/externalLinks"; -import routes from "@src/constants/routes"; -import { useAppContext } from "@src/contexts/AppContext"; - -export default function AccessDenied() { - const { currentUser } = useAppContext(); - return ( - - - You are signed in as {currentUser?.email} - - - You do not have access to this project. Please contact the project - owner. - - - If you are the project owner, please follow{" "} - - these instructions - {" "} - to set up this project’s security rules. - - - - - } - sx={{ - position: "fixed", - top: 0, - left: 0, - right: 0, - bottom: 0, - bgcolor: "background.default", - zIndex: 9999, - }} - /> - ); -} diff --git a/src/components/Home/TableGrid/TableGridSkeleton.tsx b/src/components/Home/TableGrid/TableGridSkeleton.tsx deleted file mode 100644 index ae3ec490d..000000000 --- a/src/components/Home/TableGrid/TableGridSkeleton.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Container, Paper, Box, Grid } from "@mui/material"; - -import SectionHeadingSkeleton from "@src/components/SectionHeadingSkeleton"; -import TableCardSkeleton from "./TableCardSkeleton"; - -export default function TableGridSkeleton() { - return ( - - theme.breakpoints.values.sm - 48, - width: { xs: "100%", md: "50%", lg: "100%" }, - mx: "auto", - }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/src/components/Home/TableList/TableListSkeleton.tsx b/src/components/Home/TableList/TableListSkeleton.tsx deleted file mode 100644 index 8289032d2..000000000 --- a/src/components/Home/TableList/TableListSkeleton.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Container, Box, Paper } from "@mui/material"; - -import SectionHeadingSkeleton from "@src/components/SectionHeadingSkeleton"; -import TableListItemSkeleton from "./TableListItemSkeleton"; - -export default function TableGridSkeleton() { - return ( - - theme.breakpoints.values.sm - 48, - width: { xs: "100%", md: "50%", lg: "100%" }, - mx: "auto", - }} - /> - - - - - - - - - - - - - - - - - - - ); -} diff --git a/src/components/InfoTooltip.tsx b/src/components/InfoTooltip.tsx deleted file mode 100644 index 7ba74091f..000000000 --- a/src/components/InfoTooltip.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useState } from "react"; -import _merge from "lodash/merge"; - -import { Tooltip, IconButton } from "@mui/material"; -import { alpha } from "@mui/material/styles"; -import InfoIcon from "@mui/icons-material/InfoOutlined"; -import CloseIcon from "@mui/icons-material/Close"; - -export interface IInfoTooltipProps { - description: React.ReactNode; - buttonLabel?: string; - defaultOpen?: boolean; - onClose?: () => void; - - buttonProps?: Partial>; - tooltipProps?: Partial>; - iconProps?: Partial>; -} - -export default function InfoTooltip({ - description, - buttonLabel = "Info", - defaultOpen, - onClose, - - buttonProps, - tooltipProps, - iconProps, -}: IInfoTooltipProps) { - const [open, setOpen] = useState(defaultOpen || false); - - const handleClose = () => { - setOpen(false); - if (onClose) onClose(); - }; - - const toggleOpen = () => { - if (open) { - setOpen(false); - if (onClose) onClose(); - } else { - setOpen(true); - } - }; - - return ( - - {description} - - alpha("#fff", theme.palette.action.hoverOpacity), - }, - }} - color="inherit" - > - - - - } - disableFocusListener - disableHoverListener - disableTouchListener - arrow - placement="right-start" - describeChild - {...tooltipProps} - open={open} - componentsProps={_merge( - { - tooltip: { - style: { - marginLeft: "8px", - transformOrigin: "-8px 14px", - }, - sx: { - typography: "body2", - - display: "flex", - gap: 1.5, - alignItems: "flex-start", - pr: 0.5, - }, - }, - }, - tooltipProps?.componentsProps - )} - > - - {buttonProps?.children || } - - - ); -} diff --git a/src/components/KeyValueInput.tsx b/src/components/KeyValueInput.tsx deleted file mode 100644 index da0437907..000000000 --- a/src/components/KeyValueInput.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useState } from "react"; - -import { - FormControl, - FormLabel, - FormGroup, - Stack, - TextField, - Button, -} from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import RemoveIcon from "@mui/icons-material/DeleteOutline"; - -export interface IKeyValueInputProps { - value: Record; - onChange: (value: Record) => void; - label?: React.ReactNode; -} - -export default function KeyValueInput({ - value: valueProp, - onChange, - label, -}: IKeyValueInputProps) { - const [value, setValue] = useState( - Object.keys(valueProp).length > 0 - ? Object.keys(valueProp) - .sort() - .map((key) => [key, valueProp[key]]) - : [["", ""]] - ); - - const saveValue = (v: typeof value) => { - onChange( - v.reduce((acc, [key, value]) => { - if (key.length > 0) acc[key] = value; - return acc; - }, {} as Record) - ); - }; - - const handleAdd = (i: number) => () => - setValue((v) => { - const newValue = [...v]; - newValue.splice(i + 1, 0, ["", ""]); - setTimeout(() => - document.getElementById(`keyValue-${i + 1}-key`)?.focus() - ); - return newValue; - }); - const handleRemove = (i: number) => () => - setValue((v) => { - const newValue = [...v]; - newValue.splice(i, 1); - saveValue(newValue); - return newValue; - }); - - const handleChange = - (i: number, j: number) => (e: React.ChangeEvent) => - setValue((v) => { - const newValue = [...v]; - newValue[i][j] = e.target.value; - saveValue(newValue); - return newValue; - }); - - return ( - - - {label} - - - - {value.map(([propKey, propValue], i) => ( - - - - - - - - ))} - - - - - ); -} diff --git a/src/components/Modal/FullScreenModal.tsx b/src/components/Modal/FullScreenModal.tsx index 3a30ead74..a6a3006de 100644 --- a/src/components/Modal/FullScreenModal.tsx +++ b/src/components/Modal/FullScreenModal.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useState } from "react"; +import { useState } from "react"; import { Dialog, @@ -20,9 +20,9 @@ export interface IFullScreenModalProps disableEscapeKeyDown?: boolean; "aria-labelledby": DialogProps["aria-labelledby"]; - header?: ReactNode; - children?: ReactNode; - footer?: ReactNode; + header?: React.ReactNode; + children?: React.ReactNode; + footer?: React.ReactNode; hideCloseButton?: boolean; ScrollableDialogContentProps?: Partial; diff --git a/src/components/Modal/index.tsx b/src/components/Modal/Modal.tsx similarity index 93% rename from src/components/Modal/index.tsx rename to src/components/Modal/Modal.tsx index 9ce60cee2..184a02e53 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/Modal.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useState } from "react"; +import { useState } from "react"; import { useTheme, @@ -26,12 +26,12 @@ export interface IModalProps extends Partial> { disableBackdropClick?: boolean; disableEscapeKeyDown?: boolean; - title: ReactNode; - header?: ReactNode; - footer?: ReactNode; + title: React.ReactNode; + header?: React.ReactNode; + footer?: React.ReactNode; - children?: ReactNode; - body?: ReactNode; + children?: React.ReactNode; + body?: React.ReactNode; actions?: { primary?: Partial; @@ -94,7 +94,7 @@ export default function Modal({ ...props.sx, "& .MuiDialog-paper": { height: "100%", - ...props.sx?.["& .MuiDialog-paper"], + ...(props.sx as any)?.["& .MuiDialog-paper"], }, } : props.sx diff --git a/src/components/Modal/SlideTransition.tsx b/src/components/Modal/SlideTransition.tsx index a151847dc..59c54ea93 100644 --- a/src/components/Modal/SlideTransition.tsx +++ b/src/components/Modal/SlideTransition.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import { forwardRef, cloneElement } from "react"; import { useTheme } from "@mui/material"; import { Transition } from "react-transition-group"; import { TransitionProps } from "react-transition-group/Transition"; @@ -6,7 +6,7 @@ import { TransitionProps as MuiTransitionProps } from "@mui/material/transitions export const SlideTransition: React.ForwardRefExoticComponent< Pick & React.RefAttributes -> = React.forwardRef( +> = forwardRef( ({ children, ...props }: TransitionProps, ref: React.Ref) => { const theme = useTheme(); @@ -57,8 +57,9 @@ export const SlideTransition: React.ForwardRefExoticComponent< {...props} > {(state) => - React.cloneElement(children as any, { + cloneElement(children as any, { style: { ...defaultStyle, ...transitionStyles[state] }, + tabIndex: -1, ref, }) } @@ -69,7 +70,7 @@ export const SlideTransition: React.ForwardRefExoticComponent< export default SlideTransition; -export const SlideTransitionMui = React.forwardRef(function Transition( +export const SlideTransitionMui = forwardRef(function Transition( props: MuiTransitionProps & { children?: React.ReactElement }, ref: React.Ref ) { diff --git a/src/components/Modal/index.ts b/src/components/Modal/index.ts new file mode 100644 index 000000000..7f774beb4 --- /dev/null +++ b/src/components/Modal/index.ts @@ -0,0 +1,2 @@ +export * from "./Modal"; +export { default } from "./Modal"; diff --git a/src/components/Navigation/Breadcrumbs.tsx b/src/components/Navigation/Breadcrumbs.tsx deleted file mode 100644 index fd112b537..000000000 --- a/src/components/Navigation/Breadcrumbs.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { useState } from "react"; -import _find from "lodash/find"; -import queryString from "query-string"; -import { Link as RouterLink } from "react-router-dom"; -import _camelCase from "lodash/camelCase"; -import { useAtom } from "jotai"; -import { atomWithStorage } from "jotai/utils"; - -import { - Breadcrumbs as MuiBreadcrumbs, - BreadcrumbsProps, - Link, - Typography, - Tooltip, -} from "@mui/material"; -import ArrowRightIcon from "@mui/icons-material/ChevronRight"; -import ReadOnlyIcon from "@mui/icons-material/EditOffOutlined"; - -import InfoTooltip from "@src/components/InfoTooltip"; -import RenderedMarkdown from "@src/components/RenderedMarkdown"; -import { useAppContext } from "@src/contexts/AppContext"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import useRouter from "@src/hooks/useRouter"; -import routes from "@src/constants/routes"; - -const tableDescriptionDismissedAtom = atomWithStorage( - "tableDescriptionDismissed", - [] -); - -export default function Breadcrumbs({ sx = [], ...props }: BreadcrumbsProps) { - const { userClaims } = useAppContext(); - const { tables, table, tableState } = useProjectContext(); - const id = tableState?.config.id || ""; - const collection = id || tableState?.tablePath || ""; - - const router = useRouter(); - let parentLabel = decodeURIComponent( - queryString.parse(router.location.search).parentLabel as string - ); - if (parentLabel === "undefined") parentLabel = ""; - - const breadcrumbs = collection.split("/"); - - const section = table?.section || ""; - const getLabel = (id: string) => _find(tables, ["id", id])?.name || id; - - const [dismissed, setDismissed] = useAtom(tableDescriptionDismissedAtom); - - return ( - } - aria-label="Sub-table breadcrumbs" - {...props} - sx={[ - { - "& .MuiBreadcrumbs-ol": { - userSelect: "none", - flexWrap: "nowrap", - whiteSpace: "nowrap", - }, - }, - ...(Array.isArray(sx) ? sx : [sx]), - ]} - > - {/* Section name */} - {section && ( - - {section} - - )} - - {breadcrumbs.map((crumb: string, index) => { - // If it’s the first breadcrumb, show with specific style - const crumbProps = { - key: index, - variant: "h6", - component: index === 0 ? "h1" : "div", - color: - index === breadcrumbs.length - 1 ? "textPrimary" : "textSecondary", - } as const; - - // If it’s the last crumb, just show the label without linking - if (index === breadcrumbs.length - 1) - return ( -
- - {getLabel(crumb) || crumb.replace(/([A-Z])/g, " $1")} - - {crumb === table?.id && table?.readOnly && ( - - - - )} - - {crumb === table?.id && table?.description && ( - - -
- } - buttonLabel="Table info" - tooltipProps={{ - componentsProps: { - popper: { sx: { zIndex: "appBar" } }, - tooltip: { sx: { maxWidth: "75vw" } }, - } as any, - }} - defaultOpen={!dismissed.includes(table?.id)} - onClose={() => setDismissed((d) => [...d, table?.id])} - /> - )} -
- ); - - // If odd: breadcrumb points to a document — link to rowRef - // TODO: show a picker here to switch between sub tables - if (index % 2 === 1) - return ( - - {getLabel( - parentLabel.split(",")[Math.ceil(index / 2) - 1] || crumb - )} - - ); - - // Otherwise, even: breadcrumb points to a Firestore collection - return ( - - {getLabel(crumb) || crumb.replace(/([A-Z])/g, " $1")} - - ); - })} - - ); -} diff --git a/src/components/Navigation/NavTableSection.tsx b/src/components/Navigation/NavTableSection.tsx deleted file mode 100644 index 3f7333586..000000000 --- a/src/components/Navigation/NavTableSection.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useState } from "react"; -import { useLocation } from "react-router-dom"; - -import { List, ListItemText, Collapse } from "@mui/material"; -import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; -import NavItem from "./NavItem"; - -import { Table } from "@src/contexts/ProjectContext"; -import { routes } from "@src/constants/routes"; - -export interface INavDrawerItemProps { - open?: boolean; - section: string; - tables: Table[]; - currentSection?: string; - closeDrawer?: (e: {}) => void; -} - -export default function NavDrawerItem({ - open: openProp, - section, - tables, - currentSection, - closeDrawer, -}: INavDrawerItemProps) { - const { pathname } = useLocation(); - const [open, setOpen] = useState(openProp || section === currentSection); - - return ( -
  • - setOpen((o) => !o)} - > - - - theme.transitions.create("transform"), - }} - /> - - - - - {tables - .filter((x) => x) - .map((table) => { - const route = - table.tableType === "collectionGroup" - ? `${routes.tableGroup}/${table.id}` - : `${routes.table}/${table.id.replace(/\//g, "~2F")}`; - - return ( -
  • - - `calc(100% - ${theme.spacing(2 + 0.5)})`, - }} - > - - -
  • - ); - })} - - - - ); -} diff --git a/src/components/Navigation/Notifications/index.tsx b/src/components/Navigation/Notifications/index.tsx deleted file mode 100644 index 162e2a3ea..000000000 --- a/src/components/Navigation/Notifications/index.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React from "react"; -import { - IconButton, - Popover, - List, - ListItemAvatar, - ListItem, - Avatar, - ListItemText, - ListItemSecondaryAction, - Badge, -} from "@mui/material"; - -import { makeStyles, createStyles } from "@mui/styles"; - -import ErrorIcon from "@mui/icons-material/Error"; -import DeleteIcon from "@mui/icons-material/Delete"; - -import BellIcon from "@mui/icons-material/Notifications"; - -const useStyles = makeStyles((theme) => - createStyles({ - typography: { - padding: theme.spacing(2), - }, - }) -); - -type Notification = { - title: string; - subtitle: string; - link?: string; - variant: "error" | "success" | "info" | "warning"; -}; - -const Notification = () => { - const classes = useStyles(); - const [anchorEl, setAnchorEl] = React.useState( - null - ); - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - const open = Boolean(anchorEl); - const id = open ? "simple-popover" : undefined; - - const notifications: Notification[] = [ - { - title: "a", - subtitle: "a", - variant: "error", - link: "https://console.cloud.google.com/cloud-build/builds;region=global/ID", - }, - ]; - - const notificationsCount = notifications.length; - return ( - <> - - - - - - - - {notifications.map((notification) => ( - - - - - - - - - - - - - - ))} - - - - ); -}; -export default Notification; diff --git a/src/components/RenderedHtml.tsx b/src/components/RenderedHtml.tsx deleted file mode 100644 index a88f7f4e8..000000000 --- a/src/components/RenderedHtml.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import DOMPurify from "dompurify"; -import clsx from "clsx"; - -import { makeStyles, createStyles } from "@mui/styles"; - -const useStyles = makeStyles((theme) => - createStyles({ - root: { - 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) { - const classes = useStyles(); - - return ( -
    - ); -} diff --git a/src/components/RenderedMarkdown.tsx b/src/components/RenderedMarkdown.tsx index 964739ae4..861a808cf 100644 --- a/src/components/RenderedMarkdown.tsx +++ b/src/components/RenderedMarkdown.tsx @@ -5,12 +5,12 @@ import remarkGfm from "remark-gfm"; import { Typography, Link } from "@mui/material"; const remarkPlugins = [remarkGfm]; -const components = { +const components: ReactMarkdownOptions["components"] = { a: (props) => , p: Typography, // eslint-disable-next-line jsx-a11y/alt-text img: (props) => ( - + ), }; @@ -29,7 +29,9 @@ export default function RenderedMarkdown({ return ( - createStyles({ - "@global": { - body: { - fontFamily: theme.typography.fontFamily + " !important", - }, - }, - - root: { - "& .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, - }, - }, - }, - - focus: { - "& .tox.tox-tinymce, & .tox.tox-tinymce:hover": { - boxShadow: `0 -2px 0 0 ${theme.palette.primary.main} inset, - 0 0 0 1px ${theme.palette.action.inputOutline} inset`, - }, - }, - - disabled: { - "& .tox.tox-tinymce, & .tox.tox-tinymce:hover": { - backgroundColor: - theme.palette.mode === "dark" - ? "transparent" - : theme.palette.action.disabledBackground, - }, - }, - }) -); - -export interface IRichTextEditorProps { - value?: string; - onChange: (value: string) => void; - disabled?: boolean; - id: string; -} - -export default function RichTextEditor({ - value, - onChange, - disabled, - id, -}: IRichTextEditorProps) { - const classes = useStyles(); - const theme = useTheme(); - const [focus, setFocus] = useState(false); - - return ( -
    - setFocus(true)} - onBlur={() => setFocus(false)} - /> -
    - ); -} diff --git a/src/components/RichTooltip.tsx b/src/components/RichTooltip.tsx deleted file mode 100644 index b699efe15..000000000 --- a/src/components/RichTooltip.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React, { useState } from "react"; -import clsx from "clsx"; - -import { makeStyles, createStyles } from "@mui/styles"; -import { - Tooltip, - TooltipProps, - Typography, - Button, - ButtonProps, -} from "@mui/material"; - -import { colord, extend } from "colord"; -import mixPlugin from "colord/plugins/lch"; -extend([mixPlugin]); - -const useStyles = makeStyles((theme) => - createStyles({ - popper: { - zIndex: theme.zIndex.drawer - 1, - }, - - tooltip: { - backgroundColor: - theme.palette.mode === "light" - ? theme.palette.background.default - : colord(theme.palette.background.paper) - .mix("#fff", 0.16) - .toHslString(), - boxShadow: theme.shadows[8], - - ...theme.typography.body2, - color: theme.palette.text.primary, - padding: 0, - }, - - arrow: { - "&::before": { - backgroundColor: - theme.palette.mode === "light" - ? theme.palette.background.default - : colord(theme.palette.background.paper) - .mix("#fff", 0.16) - .toHslString(), - boxShadow: theme.shadows[8], - }, - }, - - grid: { - padding: theme.spacing(2), - cursor: "default", - - display: "grid", - gridTemplateColumns: "48px auto", - gap: theme.spacing(1, 1.5), - }, - icon: { - marginTop: theme.spacing(-0.5), - fontSize: `${48 / 16}rem`, - }, - message: { - alignSelf: "center", - }, - dismissButton: { - gridColumn: 2, - justifySelf: "flex-start", - }, - }) -); - -export interface IRichTooltipProps - extends Partial> { - render: (props: { - openTooltip: () => void; - closeTooltip: () => void; - toggleTooltip: () => void; - }) => TooltipProps["children"]; - - icon?: React.ReactNode; - title: React.ReactNode; - message?: React.ReactNode; - dismissButtonText?: React.ReactNode; - dismissButtonProps?: Partial; - defaultOpen?: boolean; - onOpen?: () => void; - onClose?: () => void; - onToggle?: (state: boolean) => void; -} - -export default function RichTooltip({ - render, - icon, - title, - message, - dismissButtonText, - dismissButtonProps, - defaultOpen, - onOpen, - onClose, - onToggle, - ...props -}: IRichTooltipProps) { - const classes = useStyles(); - const [open, setOpen] = useState(defaultOpen || false); - - const openTooltip = () => { - setOpen(true); - if (onOpen) onOpen(); - }; - const closeTooltip = () => { - setOpen(false); - if (onClose) onClose(); - }; - const toggleTooltip = () => - setOpen((state) => { - if (onToggle) onToggle(!state); - return !state; - }); - - return ( - - {icon} - -
    - - {title} - - {message} -
    - - {dismissButtonText ? ( - - ) : ( - - Click to dismiss - - )} -
    - } - PopperProps={{ - modifiers: [ - { - name: "preventOverflow", - enabled: true, - options: { - altAxis: true, - altBoundary: true, - tether: false, - rootBoundary: "document", - padding: 8, - }, - }, - ], - }} - {...props} - > - {render({ openTooltip, closeTooltip, toggleTooltip })} - - ); -} diff --git a/src/components/RowyRunModal.tsx b/src/components/RowyRunModal.tsx index e48fce1ff..fb2757ad4 100644 --- a/src/components/RowyRunModal.tsx +++ b/src/components/RowyRunModal.tsx @@ -1,6 +1,5 @@ import { Link } from "react-router-dom"; import { useAtom } from "jotai"; -import { rowyRunModalAtom } from "@src/atoms/RowyRunModal"; import { Typography, @@ -13,23 +12,35 @@ import Modal from "@src/components/Modal"; import Logo from "@src/assets/LogoRowyRun"; import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; -import { useAppContext } from "@src/contexts/AppContext"; -import { routes } from "@src/constants/routes"; +import { + globalScope, + userRolesAtom, + projectSettingsAtom, + rowyRunModalAtom, +} from "@src/atoms/globalScope"; +import { ROUTES } from "@src/constants/routes"; import { WIKI_LINKS } from "@src/constants/externalLinks"; -import { useProjectContext } from "@src/contexts/ProjectContext"; +/** + * Display a modal asking the user to deploy or upgrade Rowy Run + * using `rowyRunModalAtom` in `globalState` + * {@link rowyRunModalAtom | See usage example} + */ export default function RowyRunModal() { - const { userRoles } = useAppContext(); - const { settings } = useProjectContext(); + const [userRoles] = useAtom(userRolesAtom, globalScope); + const [projectSettings] = useAtom(projectSettingsAtom, globalScope); + const [rowyRunModal, setRowyRunModal] = useAtom( + rowyRunModalAtom, + globalScope + ); - const [state, setState] = useAtom(rowyRunModalAtom); - const handleClose = () => setState((s) => ({ ...s, open: false })); + const handleClose = () => setRowyRunModal({ ...rowyRunModal, open: false }); - const showUpdateModal = state.version && settings?.rowyRunUrl; + const showUpdateModal = rowyRunModal.version && projectSettings?.rowyRunUrl; return ( {showUpdateModal ? "Update" : "Set up"} Rowy Run to use{" "} - {state.feature || "this feature"} + {rowyRunModal.feature || "this feature"} {showUpdateModal && ( - {state.feature || "This feature"} requires Rowy Run v - {state.version} or later. + {rowyRunModal.feature || "This feature"} requires Rowy Run v + {rowyRunModal.version} or later. )} @@ -73,7 +84,7 @@ export default function RowyRunModal() { diff --git a/src/components/Settings/UserSettings/Personalization.tsx b/src/components/Settings/UserSettings/Personalization.tsx index 8d9da6f58..f6f2fc2cf 100644 --- a/src/components/Settings/UserSettings/Personalization.tsx +++ b/src/components/Settings/UserSettings/Personalization.tsx @@ -1,26 +1,27 @@ import { lazy, Suspense, useState } from "react"; import { IUserSettingsChildProps } from "@src/pages/Settings/UserSettings"; -import _merge from "lodash/merge"; -import _unset from "lodash/unset"; +import { merge, unset } from "lodash-es"; import { FormControlLabel, Checkbox, Collapse } from "@mui/material"; import Loading from "@src/components/Loading"; // prettier-ignore -const ThemeColorPicker = lazy(() => import("@src/components/Settings/ThemeColorPicker") /* webpackChunkName: "Settings/ThemeColorPicker" */); +const ThemeColorPicker = lazy(() => import("@src/components/Settings/ThemeColorPicker") /* webpackChunkName: "ThemeColorPicker" */); export default function Personalization({ settings, updateSettings, }: IUserSettingsChildProps) { const [customizedThemeColor, setCustomizedThemeColor] = useState( - settings.theme?.light?.palette?.primary?.main || - settings.theme?.dark?.palette?.primary?.main + Boolean( + settings.theme?.light?.palette?.primary?.main || + settings.theme?.dark?.palette?.primary?.main + ) ); const handleSave = ({ light, dark }: { light: string; dark: string }) => { updateSettings({ - theme: _merge(settings.theme, { + theme: merge(settings.theme, { light: { palette: { primary: { main: light } } }, dark: { palette: { primary: { main: dark } } }, }), @@ -32,13 +33,13 @@ export default function Personalization({ { setCustomizedThemeColor(e.target.checked); if (!e.target.checked) { const newTheme = settings.theme; - _unset(newTheme, "light.palette.primary.main"); - _unset(newTheme, "dark.palette.primary.main"); + unset(newTheme, "light.palette.primary.main"); + unset(newTheme, "dark.palette.primary.main"); updateSettings({ theme: newTheme }); } }} @@ -49,7 +50,7 @@ export default function Personalization({ /> - }> + }> - + Theme { updateSettings({ - theme: _merge(settings.theme, { + theme: merge(settings.theme, { dark: { palette: { darker: e.target.checked } }, }), }); diff --git a/src/components/Setup/SetupLayout.tsx b/src/components/Setup/SetupLayout.tsx index 1236b350e..9d7a6b18c 100644 --- a/src/components/Setup/SetupLayout.tsx +++ b/src/components/Setup/SetupLayout.tsx @@ -1,7 +1,7 @@ import React, { useState, createElement } from "react"; import { use100vh } from "react-div-100vh"; import { SwitchTransition } from "react-transition-group"; -import type { ISetupStep } from "./types"; +import type { ISetupStep } from "./SetupStep"; import { useMediaQuery, @@ -25,7 +25,7 @@ import Logo from "@src/assets/Logo"; import ScrollableDialogContent from "@src/components/Modal/ScrollableDialogContent"; import { SlideTransition } from "@src/components/Modal/SlideTransition"; -import { analytics } from "analytics"; +import { analytics, logEvent } from "@src/analytics"; const BASE_WIDTH = 1024; @@ -73,7 +73,7 @@ export default function SetupLayout({ } const nextStepId = steps[nextIncompleteStepIndex].id; - analytics.logEvent("setup_step", { step: nextStepId }); + logEvent(analytics, "setup_step", { step: nextStepId }); setStepId(nextStepId); }; diff --git a/src/components/Setup/types.d.ts b/src/components/Setup/SetupStep.d.ts similarity index 100% rename from src/components/Setup/types.d.ts rename to src/components/Setup/SetupStep.d.ts diff --git a/src/components/Setup/SignInWithGoogle.tsx b/src/components/Setup/SignInWithGoogle.tsx index ff7369eb2..e9dfe87f5 100644 --- a/src/components/Setup/SignInWithGoogle.tsx +++ b/src/components/Setup/SignInWithGoogle.tsx @@ -1,9 +1,15 @@ import { useState } from "react"; +import { useAtom } from "jotai"; +import { signInWithPopup, GoogleAuthProvider, signOut } from "firebase/auth"; import { Typography } from "@mui/material"; import LoadingButton, { LoadingButtonProps } from "@mui/lab/LoadingButton"; -import { auth, googleProvider } from "@src/firebase"; +import { globalScope } from "@src/atoms/globalScope"; +import { firebaseAuthAtom } from "@src/sources/ProjectSourceFirebase"; + +const googleProvider = new GoogleAuthProvider(); +googleProvider.setCustomParameters({ prompt: "select_account" }); export interface ISignInWithGoogleProps extends Partial { matchEmail?: string; @@ -13,12 +19,13 @@ export default function SignInWithGoogle({ matchEmail, ...props }: ISignInWithGoogleProps) { + const [firebaseAuth] = useAtom(firebaseAuthAtom, globalScope); const [status, setStatus] = useState<"IDLE" | "LOADING" | string>("IDLE"); const handleSignIn = async () => { setStatus("LOADING"); try { - const result = await auth.signInWithPopup(googleProvider); + const result = await signInWithPopup(firebaseAuth, googleProvider); if (!result.user) throw new Error("Missing user"); if ( matchEmail && @@ -28,7 +35,7 @@ export default function SignInWithGoogle({ setStatus("IDLE"); } catch (error: any) { - if (auth.currentUser) auth.signOut(); + if (firebaseAuth.currentUser) signOut(firebaseAuth); console.log(error); setStatus(error.message); } diff --git a/src/components/Setup/Steps/StepFinish.tsx b/src/components/Setup/Steps/StepFinish.tsx index 24ab4f675..b5ee5c105 100644 --- a/src/components/Setup/Steps/StepFinish.tsx +++ b/src/components/Setup/Steps/StepFinish.tsx @@ -1,17 +1,28 @@ import { useState, useEffect } from "react"; +import { useAtom } from "jotai"; import { useSnackbar } from "notistack"; import { Link } from "react-router-dom"; -import type { ISetupStep } from "../types"; +import { doc, updateDoc } from "firebase/firestore"; +import type { ISetupStep } from "@src/components/Setup/SetupStep"; -import { Typography, Stack, RadioGroup, Radio, Button } from "@mui/material"; +import { + Typography, + Stack, + RadioGroup, + RadioGroupProps, + Radio, + Button, +} from "@mui/material"; import ThumbUpIcon from "@mui/icons-material/ThumbUpAlt"; import ThumbUpOffIcon from "@mui/icons-material/ThumbUpOffAlt"; import ThumbDownIcon from "@mui/icons-material/ThumbDownAlt"; import ThumbDownOffIcon from "@mui/icons-material/ThumbDownOffAlt"; -import { analytics } from "analytics"; -import { db } from "@src/firebase"; -import { routes } from "@src/constants/routes"; +import { analytics, logEvent } from "@src/analytics"; +import { globalScope } from "@src/atoms/globalScope"; +import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; +import { ROUTES } from "@src/constants/routes"; +import { SETTINGS } from "config/dbPaths"; export default { id: "finish", @@ -24,16 +35,17 @@ export default { } as ISetupStep; function StepFinish() { + const [firebaseDb] = useAtom(firebaseDbAtom, globalScope); const { enqueueSnackbar } = useSnackbar(); useEffect(() => { - db.doc("_rowy_/settings").update({ setupCompleted: true }); - }, []); + updateDoc(doc(firebaseDb, SETTINGS), { setupCompleted: true }); + }, [firebaseDb]); const [rating, setRating] = useState<"up" | "down" | undefined>(); - const handleRate = (e) => { - setRating(e.target.value); - analytics.logEvent("setup_rating", { rating: e.target.value }); + const handleRate: RadioGroupProps["onChange"] = (e) => { + setRating(e.target.value as typeof rating); + logEvent(analytics, "setup_rating", { rating: e.target.value }); enqueueSnackbar("Thanks for your feedback!"); }; @@ -80,7 +92,7 @@ function StepFinish() { color="primary" size="large" component={Link} - to={routes.auth} + to={ROUTES.auth} > Sign in to your Rowy project diff --git a/src/components/Setup/Steps/StepRules.tsx b/src/components/Setup/Steps/StepRules.tsx index 8a70af324..3b963d761 100644 --- a/src/components/Setup/Steps/StepRules.tsx +++ b/src/components/Setup/Steps/StepRules.tsx @@ -1,6 +1,10 @@ import { useState } from "react"; +import { useAtom } from "jotai"; import { useSnackbar } from "notistack"; -import type { ISetupStep, ISetupStepBodyProps } from "../types"; +import type { + ISetupStep, + ISetupStepBodyProps, +} from "@src/components/Setup/SetupStep"; import { Typography, @@ -13,9 +17,9 @@ import CopyIcon from "@src/assets/icons/Copy"; import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; import DoneIcon from "@mui/icons-material/Done"; -import SetupItem from "../SetupItem"; +import SetupItem from "@src/components/Setup/SetupItem"; -import { useAppContext } from "@src/contexts/AppContext"; +import { globalScope, projectIdAtom } from "@src/atoms/globalScope"; import { CONFIG } from "@src/config/dbPaths"; import { RULES_START, @@ -40,7 +44,7 @@ export default { } as ISetupStep; function StepRules({ isComplete, setComplete }: ISetupStepBodyProps) { - const { projectId } = useAppContext(); + const [projectId] = useAtom(projectIdAtom, globalScope); const { enqueueSnackbar } = useSnackbar(); const [adminRule, setAdminRule] = useState(true); diff --git a/src/components/Setup/Steps/StepStorageRules.tsx b/src/components/Setup/Steps/StepStorageRules.tsx index f1a916755..c4f2dff2a 100644 --- a/src/components/Setup/Steps/StepStorageRules.tsx +++ b/src/components/Setup/Steps/StepStorageRules.tsx @@ -1,14 +1,18 @@ +import { useAtom } from "jotai"; import { useSnackbar } from "notistack"; -import type { ISetupStep, ISetupStepBodyProps } from "../types"; +import type { + ISetupStep, + ISetupStepBodyProps, +} from "@src/components/Setup/SetupStep"; import { Typography, Button, Grid } from "@mui/material"; import CopyIcon from "@src/assets/icons/Copy"; import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; import DoneIcon from "@mui/icons-material/Done"; -import SetupItem from "../SetupItem"; +import SetupItem from "@src/components/Setup/SetupItem"; -import { useAppContext } from "@src/contexts/AppContext"; +import { globalScope, projectIdAtom } from "@src/atoms/globalScope"; import { RULES_START, RULES_END, @@ -27,7 +31,7 @@ export default { const rules = RULES_START + REQUIRED_RULES + RULES_END; function StepStorageRules({ isComplete, setComplete }: ISetupStepBodyProps) { - const { projectId } = useAppContext(); + const [projectId] = useAtom(projectIdAtom, globalScope); const { enqueueSnackbar } = useSnackbar(); return ( diff --git a/src/components/Setup/Steps/StepWelcome.tsx b/src/components/Setup/Steps/StepWelcome.tsx index 42a725b3a..dfabf8e83 100644 --- a/src/components/Setup/Steps/StepWelcome.tsx +++ b/src/components/Setup/Steps/StepWelcome.tsx @@ -1,4 +1,8 @@ -import type { ISetupStep, ISetupStepBodyProps } from "../types"; +import { useAtom } from "jotai"; +import type { + ISetupStep, + ISetupStepBodyProps, +} from "@src/components/Setup/SetupStep"; import { FormControlLabel, @@ -9,7 +13,7 @@ import { } from "@mui/material"; import { EXTERNAL_LINKS } from "@src/constants/externalLinks"; -import { useAppContext } from "@src/contexts/AppContext"; +import { globalScope, projectIdAtom } from "@src/atoms/globalScope"; export default { id: "welcome", @@ -29,7 +33,7 @@ export default { } as ISetupStep; function StepWelcome({ isComplete, setComplete }: ISetupStepBodyProps) { - const { projectId } = useAppContext(); + const [projectId] = useAtom(projectIdAtom, globalScope); return ( <> diff --git a/src/components/SideDrawer/Form/Autosave.tsx b/src/components/SideDrawer/Form/Autosave.tsx deleted file mode 100644 index bd69748eb..000000000 --- a/src/components/SideDrawer/Form/Autosave.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useEffect } from "react"; -import { useDebounce } from "use-debounce"; -import _isEqual from "lodash/isEqual"; -import _pick from "lodash/pick"; -import _pickBy from "lodash/pickBy"; - -import { Control, UseFormReturn, useWatch } from "react-hook-form"; -import { Values } from "./utils"; - -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { TableState } from "@src/hooks/useTable"; - -export interface IAutosaveProps { - control: Control; - docRef: firebase.default.firestore.DocumentReference; - row: any; - reset: UseFormReturn["reset"]; - dirtyFields: UseFormReturn["formState"]["dirtyFields"]; -} - -const getEditables = (values: Values, tableState?: TableState) => - _pick( - values, - (tableState && - (Array.isArray(tableState?.columns) - ? tableState?.columns - : Object.values(tableState?.columns) - ).map((c) => c.key)) ?? - [] - ); - -export default function Autosave({ - control, - docRef, - row, - reset, - dirtyFields, -}: IAutosaveProps) { - const { tableState, updateCell } = useProjectContext(); - - const values = useWatch({ control }); - const [debouncedValue] = useDebounce(getEditables(values, tableState), 1000, { - equalityFn: _isEqual, - }); - - useEffect(() => { - if (!row || !row.ref) return; - if (row.ref.id !== docRef.id) return; - if (!updateCell) return; - - // Get only fields that have had their value updated by the user - const updatedValues = _pickBy( - _pickBy(debouncedValue, (_, key) => dirtyFields[key]), - (value, key) => !_isEqual(value, row[key]) - ); - if (Object.keys(updatedValues).length === 0) return; - - // Update the document - Object.entries(updatedValues).forEach(([key, value]) => - updateCell( - row.ref, - key, - value, - // After the cell is updated, set this field to be not dirty - // so it doesn’t get updated again when a different field in the form - // is updated + make sure the new value is kept after reset - () => reset({ ...values, [key]: value }) - ) - ); - }, [debouncedValue]); - - return null; -} diff --git a/src/components/SideDrawer/Form/FieldSkeleton.tsx b/src/components/SideDrawer/Form/FieldSkeleton.tsx deleted file mode 100644 index e85931cc4..000000000 --- a/src/components/SideDrawer/Form/FieldSkeleton.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Skeleton, SkeletonProps } from "@mui/material"; - -export default function FieldSkeleton(props: SkeletonProps) { - return ( - - ); -} diff --git a/src/components/SideDrawer/Form/FieldWrapper.tsx b/src/components/SideDrawer/Form/FieldWrapper.tsx deleted file mode 100644 index a6952631b..000000000 --- a/src/components/SideDrawer/Form/FieldWrapper.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Suspense } from "react"; - -import { Stack, InputLabel, Typography, IconButton } from "@mui/material"; -import DocumentPathIcon from "@src/assets/icons/DocumentPath"; -import LaunchIcon from "@mui/icons-material/Launch"; -import LockIcon from "@mui/icons-material/LockOutlined"; - -import ErrorBoundary from "@src/components/ErrorBoundary"; -import FieldSkeleton from "./FieldSkeleton"; - -import { FieldType } from "@src/constants/fields"; -import { getFieldProp } from "@src/components/fields"; -import { useAppContext } from "@src/contexts/AppContext"; - -export interface IFieldWrapperProps { - children?: React.ReactNode; - type: FieldType | "debug"; - name?: string; - label?: React.ReactNode; - debugText?: React.ReactNode; - disabled?: boolean; -} - -export default function FieldWrapper({ - children, - type, - name, - label, - debugText, - disabled, -}: IFieldWrapperProps) { - const { projectId } = useAppContext(); - - return ( -
    - - {type === "debug" ? : getFieldProp("icon", type)} - - {label} - - {disabled && } - - - - }> - {children ?? - (!debugText && ( - - This field cannot be edited here. - - ))} - - - - {debugText && ( - - - {debugText} - - - - - - - )} -
    - ); -} diff --git a/src/components/SideDrawer/Form/Label.tsx b/src/components/SideDrawer/Form/Label.tsx deleted file mode 100644 index f6452a75b..000000000 --- a/src/components/SideDrawer/Form/Label.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { makeStyles, createStyles } from "@mui/styles"; -import { FormLabel, FormLabelProps, Tooltip, IconButton } from "@mui/material"; -import HelpIcon from "@mui/icons-material/HelpOutline"; - -const useStyles = makeStyles((theme) => - createStyles({ - root: { - display: "block", - marginBottom: theme.spacing(1), - }, - }) -); - -export interface ILabelProps extends FormLabelProps { - label?: React.ReactNode; - hint?: React.ReactNode; -} - -export default function Label({ - label, - children, - hint, - ...props -}: ILabelProps) { - const classes = useStyles(); - - return ( - - {label || children} - - {hint && ( - - - - - - )} - - ); -} diff --git a/src/components/SideDrawer/Form/Reset.tsx b/src/components/SideDrawer/Form/Reset.tsx deleted file mode 100644 index fd75d0385..000000000 --- a/src/components/SideDrawer/Form/Reset.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect } from "react"; -import { UseFormReturn } from "react-hook-form"; -import _pickBy from "lodash/pickBy"; -import _isEqual from "lodash/isEqual"; - -import { Values } from "./utils"; - -export interface IResetProps { - defaultValues: Values; - dirtyFields: UseFormReturn["formState"]["dirtyFields"]; - reset: UseFormReturn["reset"]; - getValues: UseFormReturn["getValues"]; -} - -/** - * Reset the form’s values and errors when the Firestore doc’s data updates - */ -export default function Reset({ - defaultValues, - dirtyFields, - reset, - getValues, -}: IResetProps) { - useEffect( - () => { - const resetValues = { ...defaultValues }; - const currentValues = getValues(); - - // If the field is dirty, (i.e. the user input a value but it hasn’t been) - // saved to the db yet, keep its current value and keep it marked as dirty - for (const [field, isDirty] of Object.entries(dirtyFields)) { - if (isDirty) { - resetValues[field] = currentValues[field]; - } - } - - // Compare currentValues to resetValues - const diff = _pickBy(getValues(), (v, k) => !_isEqual(v, resetValues[k])); - // Reset if needed & keep the current dirty fields - if (Object.keys(diff).length > 0) { - reset(resetValues, { keepDirty: true }); - } - }, - // `defaultValues` is the `initialValue` of each field type + - // the current value in the Firestore doc - [JSON.stringify(defaultValues)] - ); - - return null; -} diff --git a/src/components/SideDrawer/Form/index.tsx b/src/components/SideDrawer/Form/index.tsx deleted file mode 100644 index 2236bd7ff..000000000 --- a/src/components/SideDrawer/Form/index.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { createElement, useEffect } from "react"; -import { useForm } from "react-hook-form"; -import _sortBy from "lodash/sortBy"; -import _isEmpty from "lodash/isEmpty"; -import _set from "lodash/set"; -import createPersistedState from "use-persisted-state"; - -import { Stack, FormControlLabel, Switch } from "@mui/material"; - -import { Values } from "./utils"; -import { getFieldProp } from "@src/components/fields"; -import { IFieldConfig } from "@src/components/fields/types"; -import Autosave from "./Autosave"; -import Reset from "./Reset"; -import FieldWrapper from "./FieldWrapper"; - -import { useAppContext } from "@src/contexts/AppContext"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { sanitizeFirestoreRefs } from "@src/utils/fns"; - -const useSideDrawerShowHiddenFieldsState = createPersistedState( - "__ROWY__SIDE_DRAWER_SHOW_HIDDEN_FIELDS" -); - -export interface IFormProps { - values: Values; -} - -export default function Form({ values }: IFormProps) { - const { userDoc, userClaims } = useAppContext(); - const { table, tableState, sideDrawerRef } = useProjectContext(); - - const userDocHiddenFields = - userDoc.state.doc?.tables?.[`${tableState!.config.id}`]?.hiddenFields ?? []; - - const [showHiddenFields, setShowHiddenFields] = - useSideDrawerShowHiddenFieldsState(false); - - const fields = showHiddenFields - ? _sortBy(Object.values(tableState!.columns), "index") - : _sortBy(Object.values(tableState!.columns), "index").filter( - (f) => !userDocHiddenFields.includes(f.key) - ); - - // Get initial values from fields config. This won’t be written to the db - // when the SideDrawer is opened. Only dirty fields will be written - const initialValues = fields.reduce( - (a, { key, type }) => { - const initialValue = getFieldProp("initialValue", type); - const nextValues = { ...a }; - if (key.indexOf('.') !== -1) { - _set(nextValues, key, initialValue); - } else { - nextValues[key] = initialValue; - } - return nextValues; - }, - {} - ); - const { ref: docRef, ...rowValues } = values; - const safeRowValues = sanitizeFirestoreRefs(rowValues); - const defaultValues = { ...initialValues, ...safeRowValues }; - - const methods = useForm({ mode: "onBlur", defaultValues }); - const { control, reset, formState, getValues } = methods; - const { dirtyFields } = formState; - - const column = sideDrawerRef?.current?.cell?.column; - useEffect(() => { - if (!column) return; - - const labelElem = document.getElementById( - `sidedrawer-label-${column}` - )?.parentElement; - const fieldElem = document.getElementById(`sidedrawer-field-${column}`); - - // Time out for double-clicking on cells, which can open the null editor - setTimeout(() => { - if (labelElem) labelElem.scrollIntoView({ behavior: "smooth" }); - if (fieldElem) fieldElem.focus({ preventScroll: true }); - }, 200); - }, [column]); - - return ( -
    - - - - - - {fields.map((field, i) => { - // Derivative/aggregate field support - let type = field.type; - if (field.config && field.config.renderFieldType) { - type = field.config.renderFieldType; - } - - const fieldComponent: IFieldConfig["SideDrawerField"] = getFieldProp( - "SideDrawerField", - type - ); - - // Should not reach this state - if (_isEmpty(fieldComponent)) { - // console.error('Could not find SideDrawerField component', field); - return null; - } - - // Disable field if locked, or if table is read-only - const disabled = - field.editable === false || - Boolean(table?.readOnly && !userClaims?.roles.includes("ADMIN")); - - return ( - - {createElement(fieldComponent, { - column: field, - control, - docRef, - disabled, - useFormMethods: methods, - })} - - ); - })} - - - - {userDocHiddenFields.length > 0 && ( - setShowHiddenFields(e.target.checked)} - /> - } - sx={{ - borderTop: 1, - borderColor: "divider", - pt: 3, - "& .MuiSwitch-root": { ml: -0.5 }, - }} - /> - )} - - - ); -} diff --git a/src/components/SideDrawer/Form/utils.ts b/src/components/SideDrawer/Form/utils.ts deleted file mode 100644 index c7ff6e374..000000000 --- a/src/components/SideDrawer/Form/utils.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Control } from "react-hook-form"; -import { makeStyles, createStyles } from "@mui/styles"; -import { FieldType } from "@src/constants/fields"; -import { colord } from "colord"; - -export interface IFieldProps { - control: Control; - name: string; - docRef: firebase.default.firestore.DocumentReference; - 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 useFieldStyles = makeStyles((theme) => - createStyles({ - root: { - borderRadius: theme.shape.borderRadius, - padding: theme.spacing(0.5, 1.5), - - backgroundColor: theme.palette.action.input, - boxShadow: `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.palette.mode === "dark" - ? "transparent" - : theme.palette.action.disabledBackground, - }, - - width: "100%", - minHeight: 32, - boxSizing: "border-box", - - display: "flex", - textAlign: "left", - alignItems: "center", - - ...theme.typography.body2, - color: theme.palette.text.primary, - }, - }) -); diff --git a/src/components/SideDrawer/index.tsx b/src/components/SideDrawer/index.tsx deleted file mode 100644 index bcaaae343..000000000 --- a/src/components/SideDrawer/index.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useState, useEffect } from "react"; -import clsx from "clsx"; -import _isNil from "lodash/isNil"; -import _isEmpty from "lodash/isEmpty"; -import queryString from "query-string"; - -import { Drawer, Fab } from "@mui/material"; -import ChevronIcon from "@mui/icons-material/KeyboardArrowLeft"; -import ChevronUpIcon from "@mui/icons-material/KeyboardArrowUp"; -import ChevronDownIcon from "@mui/icons-material/KeyboardArrowDown"; - -import Form from "./Form"; -import ErrorBoundary from "@src/components/ErrorBoundary"; - -import { useStyles } from "./useStyles"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import useDoc from "@src/hooks/useDoc"; -import { analytics } from "@src/analytics"; - -export const DRAWER_WIDTH = 512; -export const DRAWER_COLLAPSED_WIDTH = 36; - -type SelectedCell = { row: number; column: string } | null; -export type SideDrawerRef = { - cell: SelectedCell; - setCell: React.Dispatch>; - open: boolean; - setOpen: React.Dispatch>; -}; - -export default function SideDrawer() { - const classes = useStyles(); - const { tableState, dataGridRef, sideDrawerRef } = useProjectContext(); - - const [cell, setCell] = useState(null); - const [open, setOpen] = useState(false); - if (sideDrawerRef) sideDrawerRef.current = { cell, setCell, open, setOpen }; - - const handleNavigate = (direction: "up" | "down") => () => { - if (!tableState?.rows) return; - let row = cell!.row; - if (direction === "up" && row > 0) row -= 1; - if (direction === "down" && row < tableState.rows.length - 1) row += 1; - setCell!((cell) => ({ column: cell!.column, row })); - const idx = tableState?.columns[cell!.column]?.index; - dataGridRef?.current?.selectCell({ rowIdx: row, idx }, false); - }; - - const [urlDocState, dispatchUrlDoc] = useDoc({}); - - useEffect(() => { - setOpen(false); - dispatchUrlDoc({ path: "", doc: null }); - }, [window.location.pathname]); - - useEffect(() => { - const rowRef = queryString.parse(window.location.search).rowRef as string; - if (rowRef) dispatchUrlDoc({ path: decodeURIComponent(rowRef) }); - }, []); - - const disabled = !open && (!cell || _isNil(cell.row)) && !urlDocState.doc; - useEffect(() => { - if (disabled && setOpen) setOpen(false); - }, [disabled]); - - useEffect(() => { - if (cell && tableState?.rows[cell.row]) { - if (urlDocState.doc) { - urlDocState.unsubscribe(); - dispatchUrlDoc({ path: "", doc: null }); - } - } - }, [cell]); - - return ( -
    - - -
    - {open && - (urlDocState.doc || cell) && - !_isEmpty(tableState?.columns) && ( -
    - )} -
    -
    - - {open && ( -
    - - - - - = tableState.rows.length - 1 - } - onClick={handleNavigate("down")} - > - - -
    - )} - -
    - { - if (setOpen) - setOpen((o) => { - analytics.logEvent( - o ? "side_drawer_close" : "side_drawer_open" - ); - return !o; - }); - }} - > - - -
    -
    -
    - ); -} diff --git a/src/components/SideDrawer/useStyles.ts b/src/components/SideDrawer/useStyles.ts deleted file mode 100644 index ff1eb50cf..000000000 --- a/src/components/SideDrawer/useStyles.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { makeStyles, createStyles } from "@mui/styles"; -import { DRAWER_WIDTH, DRAWER_COLLAPSED_WIDTH } from "./index"; -import { APP_BAR_HEIGHT } from "@src/components/Navigation"; -import { TABLE_HEADER_HEIGHT } from "@src/components/TableHeader"; - -export const useStyles = makeStyles((theme) => - createStyles({ - open: {}, - disabled: { - "& $paper": { - transform: `translateX(calc(100% - env(safe-area-inset-right) - ${DRAWER_COLLAPSED_WIDTH}px))`, - }, - "& $fab": { - transform: "scale(0)", - }, - }, - - drawer: { - width: DRAWER_WIDTH, - flexShrink: 0, - whiteSpace: "nowrap", - }, - - paper: { - border: "none", - boxShadow: theme.shadows[4].replace(/, 0 (\d+px)/g, ", -$1 0"), - borderTopLeftRadius: `${(theme.shape.borderRadius as number) * 3}px`, - borderBottomLeftRadius: `${(theme.shape.borderRadius as number) * 3}px`, - - width: DRAWER_WIDTH, - maxWidth: `calc(100% - 28px - ${theme.spacing(1)})`, - overflowX: "visible", - overflowY: "visible", - - boxSizing: "content-box", - - top: APP_BAR_HEIGHT + TABLE_HEADER_HEIGHT, - height: `calc(100% - ${APP_BAR_HEIGHT + TABLE_HEADER_HEIGHT}px)`, - - transition: theme.transitions.create("transform", { - easing: theme.transitions.easing.easeInOut, - duration: theme.transitions.duration.standard, - }), - - zIndex: theme.zIndex.drawer - 1, - }, - paperClose: { - transform: `translateX(calc(100% - env(safe-area-inset-right) - ${DRAWER_COLLAPSED_WIDTH}px))`, - }, - - "@keyframes bumpPaper": { - "0%": { - transform: `translateX(calc(100% - env(safe-area-inset-right) - ${DRAWER_COLLAPSED_WIDTH}px))`, - }, - "50%": { - transform: `translateX(calc(100% - env(safe-area-inset-right) - ${DRAWER_COLLAPSED_WIDTH}px - ${theme.spacing( - 4 - )}))`, - }, - "100%": { - transform: `translateX(calc(100% - env(safe-area-inset-right) - ${DRAWER_COLLAPSED_WIDTH}px))`, - }, - }, - bumpPaper: { - animation: `${theme.transitions.duration.standard}ms ${theme.transitions.easing.easeInOut} $bumpPaper`, - }, - - fab: { - display: "flex", - transition: theme.transitions.create("transform", { - duration: theme.transitions.duration.short, - }), - - boxShadow: theme.shadows[4], - "&:active": { boxShadow: theme.shadows[4] }, - - "&.Mui-disabled": { boxShadow: theme.shadows[4] }, - - "& + &": { marginTop: theme.spacing(4) }, - }, - - navFabContainer: { - position: "absolute", - top: theme.spacing(6), - left: -32 / 2, - zIndex: theme.zIndex.drawer + 1, - }, - "@keyframes navFab": { - from: { - opacity: 0, - transform: "translateY(-48px)", - }, - to: { - opacity: 1, - transform: "translateY(0)", - }, - }, - navFab: { - animation: `${theme.transitions.duration.standard}ms ${theme.transitions.easing.easeInOut} both $navFab`, - }, - - drawerFabContainer: { - position: "absolute", - top: "50%", - transform: "translateY(-50%)", - left: theme.spacing(-3.5), - zIndex: theme.zIndex.drawer + 1, - }, - drawerFabIcon: { - // width: "2em", - // height: "2em", - "$open &": { transform: "rotate(180deg)" }, - }, - - drawerContents: { - padding: theme.spacing(5), - paddingRight: `max(env(safe-area-inset-right), ${theme.spacing(4)})`, - paddingBottom: `max(env(safe-area-inset-bottom), ${theme.spacing(5)})`, - overflowY: "auto", - }, - }) -); diff --git a/src/components/SnackbarProgress.tsx b/src/components/SnackbarProgress.tsx deleted file mode 100644 index 5ff8f64f2..000000000 --- a/src/components/SnackbarProgress.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useState, Dispatch, SetStateAction, MutableRefObject } from "react"; - -import { Stack } from "@mui/material"; -import CircularProgressOptical from "@src/components/CircularProgressOptical"; - -export interface ISnackbarProgressRef { - setProgress: Dispatch>; - setTarget: Dispatch>; -} - -export interface ISnackbarProgressProps { - target?: number; - stateRef: MutableRefObject; -} - -export default function SnackbarProgress({ - target: targetProp = 100, - stateRef, -}: ISnackbarProgressProps) { - const [progress, setProgress] = useState(0); - const [target, setTarget] = useState(targetProp); - - stateRef.current = { setProgress, setTarget }; - - return ( - - - {progress}/{target} - - - - - ); -} diff --git a/src/components/Table/BulkActions/index.tsx b/src/components/Table/BulkActions/index.tsx deleted file mode 100644 index 4ad0859c7..000000000 --- a/src/components/Table/BulkActions/index.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { useState } from "react"; -import _find from "lodash/find"; -import { useSnackbar } from "notistack"; - -import { makeStyles, createStyles } from "@mui/styles"; -import { - alpha, - Grow, - Paper, - Grid, - Tooltip, - IconButton, - Typography, - TextField, - MenuItem, - Button, -} from "@mui/material"; -import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; - -import CopyCellsIcon from "@src/assets/icons/CopyCells"; -import ClearSelectionIcon from "@mui/icons-material/IndeterminateCheckBox"; -import DeleteIcon from "@mui/icons-material/DeleteForever"; -import ArrowDropUpIcon from "@mui/icons-material/ArrowDropUp"; - -import { useConfirmation } from "@src/components/ConfirmationDialog/Context"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { formatPath, asyncForEach } from "@src/utils/fns"; -// import routes from "@src/constants/routes"; -import { runRoutes } from "@src/constants/runRoutes"; -// import { config } from "process"; -import { WIKI_LINKS } from "@src/constants/externalLinks"; - -const useStyles = makeStyles((theme) => - createStyles({ - root: { - position: "fixed", - bottom: theme.spacing(2), - left: "50%", - transform: "translateX(-50%)", - }, - - paper: { - height: 64, - borderRadius: 32, - padding: theme.spacing(0, 1), - [theme.breakpoints.up("lg")]: { paddingRight: theme.spacing(2) }, - - zIndex: theme.zIndex.modal, - - backgroundColor: theme.palette.background.default, - - width: 470, - maxWidth: "100vw", - overflowX: "auto", - }, - - grid: { - height: "100%", - marginTop: 0, - marginBottom: 0, - }, - spacer: { width: theme.spacing(2) }, - - selectedContainer: { - flexBasis: 206, - flexShrink: 0, - }, - selected: { - color: theme.palette.text.disabled, - fontFeatureSettings: '"tnum"', - userSelect: "none", - - display: "inline-block", - marginRight: theme.spacing(1), - minWidth: 150, - }, - - dropdown: { - minWidth: 120, - margin: 0, - }, - inputBaseRoot: { - borderRadius: theme.shape.borderRadius, - backgroundColor: - theme.palette.mode === "dark" - ? alpha(theme.palette.text.primary, 0.06) - : undefined, - }, - dropdownLabel: { - left: theme.spacing(1.5), - - ...theme.typography.body1, - }, - dropdownLabelFocused: { - "$dropdownLabel&": { color: theme.palette.text.primary }, - }, - select: { - // paddingTop: "6px !important", - // paddingBottom: "7px !important", - }, - dropdownMenu: { - // marginTop: theme.spacing(-3) - }, - }) -); - -export default function BulkActions({ selectedRows, columns, clearSelection }) { - const classes = useStyles(); - const [, setLoading] = useState(); - const { - tableActions, - addRow, - tableState, - deleteRow, - rowyRun, - compatibleRowyRunVersion, - } = useProjectContext(); - - const { requestConfirmation } = useConfirmation(); - const { enqueueSnackbar } = useSnackbar(); - - const actionColumns: { name: string; key: string; config: any }[] = columns - .filter((column) => column.type === "ACTION") - .map((column) => ({ - name: column.name, - key: column.key, - config: column.config, - })); - - const handleDuplicate = () => { - asyncForEach(selectedRows, async (row) => { - const clonedRow = { ...row }; - // remove metadata - delete clonedRow.ref; - delete clonedRow.rowHeight; - Object.keys(clonedRow).forEach((key) => { - if (clonedRow[key] === undefined) delete clonedRow[key]; - }); - await addRow!(clonedRow, undefined, { type: "smaller" }); - //sleep 1 sec - await new Promise((resolve) => setTimeout(resolve, 1000)); - }); - clearSelection(); - }; - const handleDelete = () => { - deleteRow!(selectedRows.map((row) => row.ref)); - clearSelection(); - }; - - const handleActionScript = async (actionColumn, actionType) => { - const requiredVersion = "1.2.0"; - if (!compatibleRowyRunVersion!({ minVersion: requiredVersion })) { - enqueueSnackbar( - `Upgrade your Rowy run to ${requiredVersion} or above, to run bulk actions`, - { - variant: "warning", - action: ( - - ), - } - ); - return; - } - const refs = selectedRows.map((row) => { - const { ref } = row; - return { - path: ref.path, - id: ref.id, - tablePath: window.location.pathname, - }; - }); - const data = { - refs, - column: actionColumn, - action: actionType, - schemaDocPath: formatPath(tableState?.config.id ?? ""), - actionParams: {}, - }; - setLoading(true); - const result = await rowyRun!({ - route: runRoutes.actionScript, - body: data, - }); - Array.isArray(result) - ? result.map((res) => - enqueueSnackbar(res.message, { - variant: res.success ? "success" : "error", - }) - ) - : enqueueSnackbar(result.message, { - variant: result.success ? "success" : "error", - }); - setLoading(false); - clearSelection(); - }; - const executeAction = async (key: string, actionType: string) => { - const actionColumn = _find(actionColumns, { key }); - if (!actionColumn) return; - if (actionColumn.config.isActionScript) { - handleActionScript(actionColumn, actionType); - } else { - enqueueSnackbar("Callable actions not implemented yet", { - variant: "warning", - }); - } - }; - - const numSelected = selectedRows.length; - - return ( -
    - 0}> - - - - - - - - - - - {numSelected} row{numSelected !== 1 && "s"} selected - - - - - - - {/* - {`${actionColumns.length} action${ - actionColumns.length !== 1 ? "s" : "" - }`} - */} - executeAction(event.target.value, "run")} - margin="dense" - InputProps={{ - disableUnderline: true, - classes: { root: classes.inputBaseRoot }, - }} - InputLabelProps={{ - classes: { - root: classes.dropdownLabel, - focused: classes.dropdownLabelFocused, - }, - }} - SelectProps={{ - classes: { select: classes.select }, - displayEmpty: true, - MenuProps: { - anchorOrigin: { vertical: "top", horizontal: "left" }, - transformOrigin: { vertical: "bottom", horizontal: "left" }, - classes: { paper: classes.dropdownMenu }, - }, - IconComponent: ArrowDropUpIcon, - }} - label={`${actionColumns.length} action${ - actionColumns.length !== 1 ? "s" : "" - }`} - > - {actionColumns.map((action) => ( - - {action.name} - - ))} - - - - - - - - { - requestConfirmation({ - title: "Duplicate rows?", - body: `Are you sure you want to duplicate the ${numSelected} selected row${ - numSelected !== 1 ? "s" : "" - }?`, - confirm: "Duplicate rows", - handleConfirm: handleDuplicate, - }); - }} - aria-label="Duplicate selected rows" - > - - - - - - - - { - requestConfirmation({ - title: "Delete rows?", - body: `Are you sure you want to delete the ${numSelected} select row${ - numSelected !== 1 ? "s" : "" - }?`, - confirm: "Delete rows", - handleConfirm: handleDelete, - }); - }} - aria-label="Delete selected rows" - > - - - - - - - -
    - ); -} diff --git a/src/components/Table/CellValidation.tsx b/src/components/Table/CellValidation.tsx deleted file mode 100644 index ac6a97644..000000000 --- a/src/components/Table/CellValidation.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { styled } from "@mui/material/styles"; -import ErrorIcon from "@mui/icons-material/ErrorOutline"; -import WarningIcon from "@mui/icons-material/WarningAmber"; - -import RichTooltip from "@src/components/RichTooltip"; - -const Root = styled("div", { shouldForwardProp: (prop) => prop !== "error" })( - ({ theme, ...props }) => ({ - width: "100%", - height: "100%", - padding: "var(--cell-padding)", - position: "relative", - - overflow: "hidden", - contain: "strict", - display: "flex", - alignItems: "center", - - ...((props as any).error - ? { - ".rdg-cell:not([aria-selected=true]) &": { - boxShadow: `inset 0 0 0 2px ${theme.palette.error.main}`, - }, - } - : {}), - }) -); - -const Dot = styled("div")(({ theme }) => ({ - position: "absolute", - right: -5, - top: "50%", - transform: "translateY(-50%)", - zIndex: 1, - - width: 12, - height: 12, - - borderRadius: "50%", - backgroundColor: theme.palette.error.main, - - boxShadow: `0 0 0 4px var(--background-color)`, - ".rdg-row:hover &": { - boxShadow: `0 0 0 4px var(--row-hover-background-color)`, - }, -})); - -export interface ICellValidationProps - extends React.DetailedHTMLProps< - React.HTMLAttributes, - HTMLDivElement - > { - value: any; - required?: boolean; - validationRegex?: string; -} - -export default function CellValidation({ - value, - required, - validationRegex, - children, -}: ICellValidationProps) { - const isInvalid = validationRegex && !new RegExp(validationRegex).test(value); - const isMissing = required && value === undefined; - - if (isInvalid) - return ( - <> - } - title="Invalid data" - message="This row will not be saved until all the required fields contain valid data" - placement="right" - render={({ openTooltip }) => } - /> - - {children} - - ); - - if (isMissing) - return ( - <> - } - title="Required field" - message="This row will not be saved until all the required fields contain valid data" - placement="right" - render={({ openTooltip }) => } - /> - - {children} - - ); - - return {children}; -} diff --git a/src/components/Table/ColumnHeader.tsx b/src/components/Table/ColumnHeader.tsx deleted file mode 100644 index 97d2b0da2..000000000 --- a/src/components/Table/ColumnHeader.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { useRef } from "react"; -import clsx from "clsx"; -import { HeaderRendererProps } from "react-data-grid"; -import { useDrag, useDrop, DragObjectWithType } from "react-dnd"; -import useCombinedRefs from "@src/hooks/useCombinedRefs"; - -import { makeStyles, createStyles } from "@mui/styles"; -import { - alpha, - Tooltip, - 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 { FieldType } from "@src/constants/fields"; -import { getFieldProp } from "@src/components/fields"; -import { useAppContext } from "@src/contexts/AppContext"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { TableOrder } from "@src/hooks/useTable"; - -const useStyles = makeStyles((theme) => - createStyles({ - root: { - height: "100%", - "& svg, & button": { display: "block" }, - - color: theme.palette.text.secondary, - transition: theme.transitions.create("color", { - duration: theme.transitions.duration.short, - }), - "&:hover": { color: theme.palette.text.primary }, - - cursor: "move", - - padding: theme.spacing(0, 0.5, 0, 1), - width: "100%", - }, - isDragging: { opacity: 0.5 }, - isOver: { - backgroundColor: alpha( - theme.palette.primary.main, - theme.palette.action.focusOpacity - ), - color: theme.palette.primary.main, - }, - - columnNameContainer: { - flexShrink: 1, - overflow: "hidden", - margin: theme.spacing(0, 0.5), - marginRight: -30, - }, - columnName: { - ...theme.typography.caption, - fontWeight: theme.typography.fontWeightMedium, - lineHeight: "42px", - textOverflow: "clip", - }, - - columnNameTooltip: { - background: theme.palette.background.default, - color: theme.palette.text.primary, - - margin: "-41px 0 0 !important", - padding: theme.spacing(0, 1.5, 0, 0), - - "& *": { lineHeight: "40px" }, - }, - - sortIconContainer: { - backgroundColor: theme.palette.background.default, - opacity: 0, - transition: theme.transitions.create("opacity", { - duration: theme.transitions.duration.shortest, - }), - "$root:hover &": { opacity: 1 }, - }, - sortIconContainerSorted: { opacity: 1 }, - - sortIcon: { - transition: theme.transitions.create(["background-color", "transform"], { - duration: theme.transitions.duration.short, - }), - }, - sortIconAsc: { - transform: "rotate(180deg)", - }, - - dropdownButton: { - transition: theme.transitions.create("color", { - duration: theme.transitions.duration.short, - }), - - color: theme.palette.text.disabled, - "$root:hover &": { color: theme.palette.text.primary }, - }, - }) -); - -interface ColumnDragObject extends DragObjectWithType { - key: string; -} - -export default function DraggableHeaderRenderer({ - column, -}: HeaderRendererProps & { - onColumnsReorder: (sourceKey: string, targetKey: string) => void; -}) { - const classes = useStyles(); - const { userClaims } = useAppContext(); - const { tableState, tableActions, columnMenuRef } = useProjectContext(); - const [{ isDragging }, drag] = useDrag({ - item: { key: column.key, type: "COLUMN_DRAG" }, - collect: (monitor) => ({ - isDragging: !!monitor.isDragging(), - }), - }); - - const [{ isOver }, drop] = useDrop({ - accept: "COLUMN_DRAG", - drop({ key, type }: ColumnDragObject) { - if (type === "COLUMN_DRAG") { - // onColumnsReorder(key, props.column.key); - tableActions?.column.reorder(key, column.key); - } - }, - collect: (monitor) => ({ - isOver: !!monitor.isOver(), - canDrop: !!monitor.canDrop(), - }), - }); - - const headerRef = useCombinedRefs(drag, drop); - const buttonRef = useRef(null); - - if (!columnMenuRef || !tableState || !tableActions) return null; - const { orderBy } = tableState; - - const handleOpenMenu = (e: React.MouseEvent) => { - e.preventDefault(); - columnMenuRef?.current?.setSelectedColumnHeader({ - column, - anchorEl: buttonRef.current, - }); - }; - const _sortKey = getFieldProp("sortKey", (column as any).type); - const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key; - - const isSorted = orderBy?.[0]?.key === sortKey; - const isAsc = isSorted && orderBy?.[0]?.direction === "asc"; - const isDesc = isSorted && orderBy?.[0]?.direction === "desc"; - - const handleSortClick = () => { - let ordering: TableOrder = []; - - if (!isSorted) ordering = [{ key: sortKey, direction: "desc" }]; - else if (isDesc) ordering = [{ key: sortKey, direction: "asc" }]; - else ordering = []; - - tableActions.table.orderBy(ordering); - }; - - return ( - - {(column.width as number) > 140 && ( - - Click to copy field key: -
    - {column.key} - - } - enterDelay={1000} - placement="bottom-start" - > - { - navigator.clipboard.writeText(column.key); - }} - > - {column.editable === false ? ( - - ) : ( - getFieldProp("icon", (column as any).type) - )} - -
    - )} - - - - {column.name as string} - - } - 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} - classes={{ tooltip: classes.columnNameTooltip }} - > - - {column.name as string} - - - - - {(column as any).type !== FieldType.id && ( - - - - - - - - )} - - {(userClaims?.roles?.includes("ADMIN") || - (userClaims?.roles?.includes("OPS") && - [FieldType.multiSelect, FieldType.singleSelect].includes( - (column as any).type - ))) && ( - - - - - - - - )} -
    - ); - // return ( - //
    - // {props.column.name as string} - //
    - // ); -} diff --git a/src/components/Table/ColumnMenu/FieldSettings/DefaultValueInput.tsx b/src/components/Table/ColumnMenu/FieldSettings/DefaultValueInput.tsx deleted file mode 100644 index 7dc23539f..000000000 --- a/src/components/Table/ColumnMenu/FieldSettings/DefaultValueInput.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { lazy, Suspense, createElement } from "react"; -import { useForm } from "react-hook-form"; -import { IMenuModalProps } from ".."; - -import Checkbox from "@mui/material/Checkbox"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import { Typography, TextField, MenuItem, ListItemText } from "@mui/material"; - -import { getFieldProp } from "@src/components/fields"; -import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton"; -import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper"; -import FormAutosave from "./FormAutosave"; -import { FieldType } from "@src/constants/fields"; -import { WIKI_LINKS } from "@src/constants/externalLinks"; -import { name } from "@root/package.json"; -/* eslint-disable import/no-webpack-loader-syntax */ -import defaultValueDefs from "!!raw-loader!./defaultValue.d.ts"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -const _CodeEditor = lazy( - () => - import("@src/components/CodeEditor" /* webpackChunkName: "CodeEditor" */) -); - -const diagnosticsOptions = { - noSemanticValidation: false, - noSyntaxValidation: false, - noSuggestionDiagnostics: true, -}; - -export interface IDefaultValueInputProps extends IMenuModalProps { - handleChange: (key: any) => (update: any) => void; -} - -const CodeEditor = ({ type, config, handleChange }) => { - const { compatibleRowyRunVersion } = useProjectContext(); - const functionBodyOnly = compatibleRowyRunVersion!({ maxVersion: "1.3.10" }); - const returnType = getFieldProp("dataType", type) ?? "any"; - - const dynamicValueFn = functionBodyOnly - ? config.defaultValue?.script - : config.defaultValue?.dynamicValueFn - ? config.defaultValue?.dynamicValueFn - : config.defaultValue?.script - ? `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth})=>{ - ${config.defaultValue.script} - }` - : `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth})=>{ - // Write your default value code here - // for example: - // generate random hex color - // const color = "#" + Math.floor(Math.random() * 16777215).toString(16); - // return color; - // checkout the documentation for more info: https://docs.rowy.io/how-to/default-values#dynamic - }`; - return ( - <_CodeEditor - value={dynamicValueFn} - diagnosticsOptions={functionBodyOnly ? undefined : diagnosticsOptions} - extraLibs={[ - defaultValueDefs.replace( - `"PLACEHOLDER_OUTPUT_TYPE"`, - `${returnType} | Promise<${returnType}>` - ), - ]} - onChange={handleChange( - functionBodyOnly ? "defaultValue.script" : "defaultValue.dynamicValueFn" - )} - /> - ); -}; - -export default function DefaultValueInput({ - config, - handleChange, - type, - fieldName, - ...props -}: IDefaultValueInputProps) { - const { settings } = useProjectContext(); - - const _type = - type !== FieldType.derivative - ? type - : config.renderFieldType ?? FieldType.shortText; - const customFieldInput = getFieldProp("SideDrawerField", _type); - const { control } = useForm({ - mode: "onBlur", - defaultValues: { - [fieldName]: - config.defaultValue?.value ?? getFieldProp("initialValue", _type), - }, - }); - - return ( - <> - handleChange("defaultValue.type")(e.target.value)} - fullWidth - sx={{ mb: 1 }} - > - - - - - - Initialise as null. - - } - /> - - - - - - - Dynamic —{" "} - - Requires Rowy Run setup - - - ) - } - secondary="Write code to set the default value using Rowy Run" - /> - - - {(!config.defaultValue || config.defaultValue.type === "undefined") && ( - <> - - Make this column required - - The row will not be created or updated unless all required - values are set. - - - } - control={ - handleChange("required")(e.target.checked)} - name="required" - /> - } - /> - - )} - {config.defaultValue?.type === "static" && customFieldInput && ( - - - handleChange("defaultValue.value")(values[fieldName]) - } - /> - - {createElement(customFieldInput, { - column: { type, key: fieldName, config, ...props, ...config }, - control, - docRef: {}, - disabled: false, - })} - - )} - - {config.defaultValue?.type === "dynamic" && ( - <> - - }> - - - - )} - - ); -} diff --git a/src/components/Table/ColumnMenu/FieldSettings/FormAutosave.tsx b/src/components/Table/ColumnMenu/FieldSettings/FormAutosave.tsx deleted file mode 100644 index 52fda33db..000000000 --- a/src/components/Table/ColumnMenu/FieldSettings/FormAutosave.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect } from "react"; -import { useDebounce } from "use-debounce"; -import _isEqual from "lodash/isEqual"; - -import { Control, useWatch } from "react-hook-form"; - -export interface IAutosaveProps { - control: Control; - handleSave: (values: any) => void; - debounce?: number; -} - -export default function FormAutosave({ - control, - handleSave, - debounce = 1000, -}: IAutosaveProps) { - const values = useWatch({ control }); - - const [debouncedValue] = useDebounce(values, debounce, { - equalityFn: _isEqual, - }); - - useEffect(() => { - handleSave(debouncedValue); - }, [debouncedValue]); - - return null; -} diff --git a/src/components/Table/ColumnMenu/FieldSettings/defaultValue.d.ts b/src/components/Table/ColumnMenu/FieldSettings/defaultValue.d.ts deleted file mode 100644 index 0e50bb2d5..000000000 --- a/src/components/Table/ColumnMenu/FieldSettings/defaultValue.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -type DefaultValueContext = { - row: Row; - ref: FirebaseFirestore.DocumentReference; - storage: firebasestorage.Storage; - db: FirebaseFirestore.Firestore; - auth: firebaseauth.BaseAuth; -}; -type DefaultValue = (context: DefaultValueContext) => "PLACEHOLDER_OUTPUT_TYPE"; diff --git a/src/components/Table/ColumnMenu/FieldSettings/index.tsx b/src/components/Table/ColumnMenu/FieldSettings/index.tsx deleted file mode 100644 index 989be3f1a..000000000 --- a/src/components/Table/ColumnMenu/FieldSettings/index.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { useState, Suspense, useMemo, createElement } from "react"; -import _set from "lodash/set"; -import { IMenuModalProps } from ".."; - -import { Typography, Stack } from "@mui/material"; - -import Modal from "@src/components/Modal"; -import { getFieldProp } from "@src/components/fields"; -import DefaultValueInput from "./DefaultValueInput"; -import ErrorBoundary from "@src/components/ErrorBoundary"; -import Loading from "@src/components/Loading"; - -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { useConfirmation } from "@src/components/ConfirmationDialog"; -import { useSnackLogContext } from "@src/contexts/SnackLogContext"; -import { FieldType } from "@src/constants/fields"; -import { runRoutes } from "@src/constants/runRoutes"; -import { useSnackbar } from "notistack"; - -export default function FieldSettings(props: IMenuModalProps) { - const { name, fieldName, type, open, config, handleClose, handleSave } = - props; - - const [showRebuildPrompt, setShowRebuildPrompt] = useState(false); - const [newConfig, setNewConfig] = useState(config ?? {}); - const customFieldSettings = getFieldProp("settings", type); - const settingsValidator = getFieldProp("settingsValidator", type); - const initializable = getFieldProp("initializable", type); - - const { requestConfirmation } = useConfirmation(); - const { enqueueSnackbar } = useSnackbar(); - const { tableState, rowyRun } = useProjectContext(); - const snackLogContext = useSnackLogContext(); - - const rendedFieldSettings = useMemo( - () => - [FieldType.derivative, FieldType.aggregate].includes(type) && - newConfig.renderFieldType - ? getFieldProp("settings", newConfig.renderFieldType) - : null, - [newConfig.renderFieldType, type] - ); - - const [errors, setErrors] = useState({}); - - if (!open) return null; - - const validateSettings = () => { - if (settingsValidator) { - const errors = settingsValidator(newConfig); - setErrors(errors); - return errors; - } - setErrors({}); - return {}; - }; - - const handleChange = (key: string) => (update: any) => { - if ( - showRebuildPrompt === false && - (key.includes("defaultValue") || type === FieldType.derivative) && - config[key] !== update - ) { - setShowRebuildPrompt(true); - } - const updatedConfig = _set({ ...newConfig }, key, update); - setNewConfig(updatedConfig); - validateSettings(); - }; - - return ( - }> - <> - {initializable && ( - <> -
    - {/* top margin fixes visual bug */} - - - -
    - - )} - - {customFieldSettings && ( - - {createElement(customFieldSettings, { - config: newConfig, - onChange: handleChange, - fieldName, - onBlur: validateSettings, - errors, - })} - - )} - - {rendedFieldSettings && ( - - - Rendered field config - - {createElement(rendedFieldSettings, { - config: newConfig, - onChange: handleChange, - onBlur: validateSettings, - errors, - })} - - )} - {/* { - - } */} - -
    - } - actions={{ - primary: { - onClick: () => { - const errors = validateSettings(); - if (Object.keys(errors).length > 0) { - requestConfirmation({ - title: "Invalid settings", - customBody: ( - <> - Please fix the following settings: -
      - {Object.entries(errors).map(([key, message]) => ( -
    • - {key}: {message} -
    • - ))} -
    - - ), - confirm: "Fix", - hideCancel: true, - handleConfirm: () => {}, - }); - return; - } - if (showRebuildPrompt) { - enqueueSnackbar("Saving changes...", { - autoHideDuration: 1500, - }); - handleSave(fieldName, { config: newConfig }, () => { - requestConfirmation({ - title: "Deploy changes", - body: "You have made changes that affect the behavior of the cloud function of this table, Would you like to redeploy it now?", - confirm: "Deploy", - cancel: "Later", - handleConfirm: async () => { - if (!rowyRun) return; - snackLogContext.requestSnackLog(); - rowyRun({ - route: runRoutes.buildFunction, - body: { - tablePath: tableState?.tablePath, - pathname: window.location.pathname, - tableConfigPath: tableState?.config.tableConfig.path, - }, - }); - }, - }); - }); - } else { - handleSave(fieldName, { config: newConfig }); - } - - handleClose(); - setShowRebuildPrompt(false); - }, - children: "Update", - }, - secondary: { - onClick: handleClose, - children: "Cancel", - }, - }} - /> - ); -} diff --git a/src/components/Table/ColumnMenu/FieldsDropdown.tsx b/src/components/Table/ColumnMenu/FieldsDropdown.tsx deleted file mode 100644 index a25020ff9..000000000 --- a/src/components/Table/ColumnMenu/FieldsDropdown.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import MultiSelect from "@rowy/multiselect"; -import { ListItemIcon } from "@mui/material"; - -import { FIELDS } from "@src/components/fields"; -import { FieldType } from "@src/constants/fields"; -import { getFieldProp } from "@src/components/fields"; - -export interface IFieldsDropdownProps { - value: FieldType; - onChange: (value: FieldType) => void; - hideLabel?: boolean; - label?: string; - options?: FieldType[]; - [key: string]: any; -} - -/** - * Returns dropdown component of all available types - */ -export default function FieldsDropdown({ - value, - onChange, - hideLabel = false, - label, - options: optionsProp, - ...props -}: IFieldsDropdownProps) { - const options = optionsProp - ? FIELDS.filter((fieldConfig) => optionsProp.indexOf(fieldConfig.type) > -1) - : FIELDS; - - return ( - ({ - label: fieldConfig.name, - value: fieldConfig.type, - }))} - {...({ - AutocompleteProps: { - groupBy: (option) => getFieldProp("group", option.value), - }, - } as any)} - itemRenderer={(option) => ( - <> - - {getFieldProp("icon", option.value as FieldType)} - - {option.label} - - )} - label={label || "Field type"} - labelPlural="field types" - TextFieldProps={{ - hiddenLabel: hideLabel, - helperText: value && getFieldProp("description", value), - ...props.TextFieldProps, - SelectProps: { - displayEmpty: true, - renderValue: () => ( - <> - - {getFieldProp("icon", value as FieldType)} - - {getFieldProp("name", value as FieldType)} - - ), - ...props.TextFieldProps?.SelectProps, - }, - }} - /> - ); -} diff --git a/src/components/Table/ColumnMenu/MenuContents.tsx b/src/components/Table/ColumnMenu/MenuContents.tsx deleted file mode 100644 index d442b77d9..000000000 --- a/src/components/Table/ColumnMenu/MenuContents.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Fragment } from "react"; - -import { MenuItem, ListItemIcon, ListSubheader, Divider } from "@mui/material"; - -export interface IMenuContentsProps { - menuItems: { - type?: string; - label?: string; - activeLabel?: string; - icon?: JSX.Element; - activeIcon?: JSX.Element; - onClick?: () => void; - active?: boolean; - color?: "error"; - disabled?: boolean; - }[]; -} - -export default function MenuContents({ menuItems }: IMenuContentsProps) { - return ( - <> - {menuItems.map((item, index) => { - if (item.type === "subheader") - return ( - - - {item.label && ( - {item.label} - )} - - ); - - let icon: JSX.Element = item.icon ?? <>; - if (item.active && !!item.activeIcon) icon = item.activeIcon; - - return ( - - {icon} - {item.active ? item.activeLabel : item.label} - - ); - })} - - ); -} diff --git a/src/components/Table/ColumnMenu/NameChange.tsx b/src/components/Table/ColumnMenu/NameChange.tsx deleted file mode 100644 index 28d20af17..000000000 --- a/src/components/Table/ColumnMenu/NameChange.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useState } from "react"; -import { IMenuModalProps } from "."; - -import { TextField } from "@mui/material"; - -import Modal from "@src/components/Modal"; - -export default function NameChange({ - name, - fieldName, - open, - handleClose, - handleSave, -}: IMenuModalProps) { - const [newName, setName] = useState(name); - - if (!open) return null; - - return ( - { - setName(e.target.value); - }} - /> - } - actions={{ - primary: { - onClick: () => { - handleSave(fieldName, { name: newName }); - handleClose(); - }, - children: "Update", - }, - secondary: { - onClick: handleClose, - children: "Cancel", - }, - }} - /> - ); -} diff --git a/src/components/Table/ColumnMenu/NewColumn.tsx b/src/components/Table/ColumnMenu/NewColumn.tsx deleted file mode 100644 index e23ec00eb..000000000 --- a/src/components/Table/ColumnMenu/NewColumn.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useState, useEffect } from "react"; -import _camel from "lodash/camelCase"; -import { IMenuModalProps } from "."; - -import { TextField, Typography, Button } from "@mui/material"; - -import Modal from "@src/components/Modal"; -import FieldsDropdown from "./FieldsDropdown"; - -import { FieldType } from "@src/constants/fields"; -import { getFieldProp } from "@src/components/fields"; -import { analytics } from "analytics"; -import { useProjectContext } from "@src/contexts/ProjectContext"; - -const AUDIT_FIELD_TYPES = [ - FieldType.createdBy, - FieldType.createdAt, - FieldType.updatedBy, - FieldType.updatedAt, -]; -export interface INewColumnProps extends IMenuModalProps { - data: Record; - openSettings: (column: any) => void; -} -export default function NewColumn({ - open, - data, - openSettings, - handleClose, -}: INewColumnProps) { - const { settingsActions, table, tableActions } = useProjectContext(); - const [columnLabel, setColumnLabel] = useState(""); - const [fieldKey, setFieldKey] = useState(""); - const [type, setType] = useState(FieldType.shortText); - const requireConfiguration = getFieldProp("requireConfiguration", type); - - const isAuditField = AUDIT_FIELD_TYPES.includes(type); - - useEffect(() => { - switch (type) { - case FieldType.id: - setColumnLabel("ID"); - setFieldKey("id"); - break; - case FieldType.createdBy: - setColumnLabel("Created By"); - setFieldKey(table?.auditFieldCreatedBy || "_createdBy"); - break; - case FieldType.updatedBy: - setColumnLabel("Updated By"); - setFieldKey(table?.auditFieldUpdatedBy || "_updatedBy"); - break; - case FieldType.createdAt: - setColumnLabel("Created At"); - setFieldKey( - (table?.auditFieldCreatedBy || "_createdBy") + ".timestamp" - ); - break; - case FieldType.updatedAt: - setColumnLabel("Updated At"); - setFieldKey( - (table?.auditFieldUpdatedBy || "_updatedBy") + ".timestamp" - ); - break; - } - }, [type, table?.auditFieldCreatedBy, table?.auditFieldUpdatedBy]); - - if (!open) return null; - - return ( - -
    - { - setColumnLabel(e.target.value); - if (type !== FieldType.id && !isAuditField) { - setFieldKey(_camel(e.target.value)); - } - }} - helperText="Set the user-facing name for this column." - /> -
    - -
    - setFieldKey(e.target.value)} - disabled={ - (type === FieldType.id && fieldKey === "id") || isAuditField - } - helperText="Set the Firestore field key to link to this column. It will display any existing data for this field key." - sx={{ "& .MuiInputBase-input": { fontFamily: "mono" } }} - /> -
    - -
    - -
    - - {isAuditField && table?.audit === false && ( -
    - - This field requires auditing to be enabled on this table. - - - -
    - )} - - } - actions={{ - primary: { - onClick: () => { - tableActions?.column.insert( - { - type, - name: columnLabel, - fieldName: fieldKey, - key: fieldKey, - config: {}, - }, - { - insert: data.insert, - index: data.sourceIndex, - } - ); - if (requireConfiguration) { - openSettings({ - type, - name: columnLabel, - fieldName: fieldKey, - key: fieldKey, - config: {}, - }); - } else handleClose(); - analytics.logEvent("create_column", { - type, - }); - }, - disabled: !columnLabel || !fieldKey || !type, - children: requireConfiguration ? "Next" : "Add", - }, - secondary: { - onClick: handleClose, - children: "Cancel", - }, - }} - /> - ); -} diff --git a/src/components/Table/ColumnMenu/Subheading.tsx b/src/components/Table/ColumnMenu/Subheading.tsx deleted file mode 100644 index acf23d6d3..000000000 --- a/src/components/Table/ColumnMenu/Subheading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useTheme, Typography, TypographyProps } from "@mui/material"; - -export default function Subheading(props: TypographyProps<"h2">) { - const theme = useTheme(); - - return ( - - ); -} diff --git a/src/components/Table/ColumnMenu/TypeChange.tsx b/src/components/Table/ColumnMenu/TypeChange.tsx deleted file mode 100644 index 9d1e9b1ad..000000000 --- a/src/components/Table/ColumnMenu/TypeChange.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useState } from "react"; - -import { IMenuModalProps } from "."; -import Modal from "@src/components/Modal"; -import FieldsDropdown from "./FieldsDropdown"; -import { analytics } from "analytics"; -export default function FormDialog({ - fieldName, - type, - open, - handleClose, - handleSave, -}: IMenuModalProps) { - const [newType, setType] = useState(type); - - if (!open) return null; - - return ( - } - actions={{ - primary: { - onClick: () => { - handleSave(fieldName, { type: newType }); - handleClose(); - analytics.logEvent("change_column_type", { - newType, - prevType: type, - }); - }, - children: "Update", - }, - }} - maxWidth="xs" - /> - ); -} diff --git a/src/components/Table/ColumnMenu/index.tsx b/src/components/Table/ColumnMenu/index.tsx deleted file mode 100644 index 3b5e98d29..000000000 --- a/src/components/Table/ColumnMenu/index.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import React, { useState, useEffect } from "react"; - -import { - Menu, - ListItem, - ListItemIcon, - ListItemText, - Typography, -} from "@mui/material"; -import LockOpenIcon from "@mui/icons-material/LockOpen"; -import LockIcon from "@mui/icons-material/LockOutlined"; -// import VisibilityOffIcon from "@mui/icons-material/VisibilityOffOutlined"; -// import VisibilityIcon from "@mui/icons-material/VisibilityOutlined"; -import FreezeIcon from "@src/assets/icons/Freeze"; -import UnfreezeIcon from "@src/assets/icons/Unfreeze"; -import CellResizeIcon from "@src/assets/icons/CellResize"; -import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; -import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; -import EditIcon from "@mui/icons-material/EditOutlined"; -// import ReorderIcon from "@mui/icons-material/Reorder"; -import SettingsIcon from "@mui/icons-material/SettingsOutlined"; -import ColumnPlusBeforeIcon from "@src/assets/icons/ColumnPlusBefore"; -import ColumnPlusAfterIcon from "@src/assets/icons/ColumnPlusAfter"; -import ColumnRemoveIcon from "@src/assets/icons/ColumnRemove"; - -import MenuContents from "./MenuContents"; -import NameChange from "./NameChange"; -import NewColumn from "./NewColumn"; -import TypeChange from "./TypeChange"; -import FieldSettings from "./FieldSettings"; -import ColumnHeader from "@src/components/Wizards/Column"; - -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { FieldType } from "@src/constants/fields"; -import { getFieldProp } from "@src/components/fields"; - -import { Column } from "react-data-grid"; -import { PopoverProps } from "@mui/material"; -import { useConfirmation } from "@src/components/ConfirmationDialog"; -import { analytics } from "@src/analytics"; - -const INITIAL_MODAL = { type: "", data: {} }; - -enum ModalStates { - nameChange = "NAME_CHANGE", - typeChange = "TYPE_CHANGE", - new = "NEW_COLUMN", - settings = "COLUMN_SETTINGS", -} - -type SelectedColumnHeader = { - column: Column & { [key: string]: any }; - anchorEl: PopoverProps["anchorEl"]; -}; - -export type ColumnMenuRef = { - selectedColumnHeader: SelectedColumnHeader | null; - setSelectedColumnHeader: React.Dispatch< - React.SetStateAction - >; -}; - -export interface IMenuModalProps { - name: string; - fieldName: string; - type: FieldType; - - open: boolean; - config: Record; - - handleClose: () => void; - handleSave: ( - fieldName: string, - config: Record, - onSuccess?: Function - ) => void; -} - -export default function ColumnMenu() { - const [modal, setModal] = useState(INITIAL_MODAL); - const { tableState, tableActions, columnMenuRef } = useProjectContext(); - const { requestConfirmation } = useConfirmation(); - - const [selectedColumnHeader, setSelectedColumnHeader] = useState(null); - if (columnMenuRef) - columnMenuRef.current = { - selectedColumnHeader, - setSelectedColumnHeader, - } as any; - - const { column, anchorEl } = (selectedColumnHeader ?? {}) as any; - - useEffect(() => { - if (column && column.type === FieldType.last) { - setModal({ - type: ModalStates.new, - data: {}, - }); - } - }, [column]); - if (!tableState || !tableActions) return null; - const { orderBy } = tableState; - - const actions = tableActions!.column; - - const handleClose = () => { - if (!setSelectedColumnHeader) return; - setSelectedColumnHeader({ - column: column!, - anchorEl: null, - }); - setTimeout(() => setSelectedColumnHeader(null), 300); - }; - - const isConfigurable = Boolean( - getFieldProp("settings", column?.type) || - getFieldProp("initializable", column?.type) - ); - - if (!column) return null; - const _sortKey = getFieldProp("sortKey", (column as any).type); - const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key; - const isSorted = orderBy?.[0]?.key === sortKey; - const isAsc = isSorted && orderBy?.[0]?.direction === "asc"; - - const clearModal = () => { - setModal(INITIAL_MODAL); - setTimeout(() => handleClose(), 300); - }; - - const handleModalSave = ( - key: string, - update: Record, - onSuccess?: Function - ) => { - actions.update(key, update, onSuccess); - }; - const openSettings = (column) => { - setSelectedColumnHeader({ - column, - }); - setModal({ type: ModalStates.settings, data: { column } }); - }; - const menuItems = [ - { type: "subheader" }, - { - label: "Lock", - activeLabel: "Unlock", - icon: , - activeIcon: , - onClick: () => { - actions.update(column.key, { editable: !column.editable }); - handleClose(); - }, - active: !column.editable, - }, - { - label: "Freeze", - activeLabel: "Unfreeze", - icon: , - activeIcon: , - onClick: () => { - actions.update(column.key, { fixed: !column.fixed }); - handleClose(); - }, - active: column.fixed, - }, - { - label: "Enable resize", - activeLabel: "Disable resize", - icon: , - onClick: () => { - actions.update(column.key, { resizable: !column.resizable }); - handleClose(); - }, - active: column.resizable, - }, - { - label: "Sort: descending", - activeLabel: "Sorted: descending", - icon: , - onClick: () => { - tableActions.table.orderBy( - isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }] - ); - handleClose(); - }, - active: isSorted && !isAsc, - disabled: column.type === FieldType.id, - }, - { - label: "Sort: ascending", - activeLabel: "Sorted: ascending", - icon: , - onClick: () => { - tableActions.table.orderBy( - isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }] - ); - handleClose(); - }, - active: isSorted && isAsc, - disabled: column.type === FieldType.id, - }, - { type: "subheader" }, - { - label: "Add new to left…", - icon: , - onClick: () => - setModal({ - type: ModalStates.new, - data: { - insert: "left", - sourceIndex: column.index, - }, - }), - }, - { - label: "Add new to right…", - icon: , - onClick: () => - setModal({ - type: ModalStates.new, - data: { - insert: "right", - sourceIndex: column.index, - }, - }), - }, - { type: "subheader" }, - { - label: "Rename…", - icon: , - onClick: () => { - setModal({ type: ModalStates.nameChange, data: {} }); - }, - }, - { - label: `Edit type: ${getFieldProp("name", column.type)}…`, - // This is based off the cell type - icon: getFieldProp("icon", column.type), - onClick: () => { - setModal({ type: ModalStates.typeChange, data: { column } }); - }, - }, - { - label: `Column settings…`, - // This is based off the cell type - icon: , - onClick: () => { - openSettings(column); - }, - disabled: !isConfigurable, - }, - // { - // label: "Re-order", - // icon: , - // onClick: () => alert("REORDER"), - // }, - - // { - // label: "Hide for everyone", - // activeLabel: "Show", - // icon: , - // activeIcon: , - // onClick: () => { - // actions.update(column.key, { hidden: !column.hidden }); - // handleClose(); - // }, - // active: column.hidden, - // color: "error" as "error", - // }, - { - label: "Delete column…", - icon: , - onClick: () => - requestConfirmation({ - title: "Delete column?", - customBody: ( - <> - - Only the column configuration will be deleted. No data will be - deleted. - - - - Key: {column.key} - - - ), - confirm: "Delete", - confirmColor: "error", - handleConfirm: async () => { - actions.remove(column.key); - await analytics.logEvent("delete_column", { type: column.type }); - handleClose(); - }, - }), - color: "error" as "error", - }, - ]; - - const menuModalProps = { - name: column.name, - fieldName: column.key, - type: column.type, - - open: modal.type === ModalStates.typeChange, - config: column.config, - - handleClose: clearModal, - handleSave: handleModalSave, - }; - - return ( - <> - {column.type !== FieldType.last && ( - - - - {getFieldProp("icon", column.type)} - - - Key: {column.key} - - } - primaryTypographyProps={{ variant: "subtitle2" }} - secondaryTypographyProps={{ variant: "caption" }} - sx={{ m: 0, minHeight: 40, "& > *": { userSelect: "none" } }} - /> - - - - )} - {column && ( - <> - - - - - - )} - - ); -} diff --git a/src/components/Table/ContextMenu/MenuContent.tsx b/src/components/Table/ContextMenu/MenuContent.tsx deleted file mode 100644 index 8e28bfd61..000000000 --- a/src/components/Table/ContextMenu/MenuContent.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Divider, Menu } from "@mui/material"; -import { default as MenuItem } from "./MenuItem"; -import { IContextMenuItem } from "./MenuItem"; - -interface IMenuContents { - anchorEl: HTMLElement; - open: boolean; - handleClose: () => void; - groups: IContextMenuItem[][]; -} - -export default function MenuContents({ - anchorEl, - open, - handleClose, - groups, -}: IMenuContents) { - const handleContext = (e: React.MouseEvent) => e.preventDefault(); - - return ( - - {groups.map((items, groupIndex) => ( - <> - {groupIndex > 0 && } - {items.map((item, index: number) => ( - - ))} - - ))} - - ); -} diff --git a/src/components/Table/ContextMenu/MenuItem.tsx b/src/components/Table/ContextMenu/MenuItem.tsx deleted file mode 100644 index 63eaa8335..000000000 --- a/src/components/Table/ContextMenu/MenuItem.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { - ListItemIcon, - ListItemText, - MenuItem, - MenuItemProps, - Typography, -} from "@mui/material"; - -export interface IContextMenuItem extends Partial { - onClick: () => void; - icon: JSX.Element; - label: string; - disabled?: boolean; - hotkeyLabel?: string; -} - -export default function ContextMenuItem({ - onClick, - icon, - label, - disabled, - hotkeyLabel, - ...props -}: IContextMenuItem) { - return ( - - {icon} - {label} - {hotkeyLabel && ( - - {hotkeyLabel} - - )} - - ); -} diff --git a/src/components/Table/ContextMenu/index.tsx b/src/components/Table/ContextMenu/index.tsx deleted file mode 100644 index 1475f4b60..000000000 --- a/src/components/Table/ContextMenu/index.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import _find from "lodash/find"; -import { getFieldProp } from "@src/components/fields"; - -import MenuContents from "./MenuContent"; -import DuplicateIcon from "@src/assets/icons/CopyCells"; -import DeleteIcon from "@mui/icons-material/DeleteOutlined"; -import LinkIcon from "@mui/icons-material/Link"; - -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { useContextMenuAtom } from "@src/atoms/ContextMenu"; -import { FieldType } from "@src/constants/fields"; - -import { useAppContext } from "@src/contexts/AppContext"; -import { IContextMenuItem } from "./MenuItem"; -import { useConfirmation } from "@src/components/ConfirmationDialog/Context"; - -export default function ContextMenu() { - const { requestConfirmation } = useConfirmation(); - const { tableState, deleteRow, addRow } = useProjectContext(); - const { userRoles } = useAppContext(); - - const { anchorEle, selectedCell, resetContextMenu } = useContextMenuAtom(); - - const columns = tableState?.columns; - const selectedColIndex = selectedCell?.colIndex; - const selectedColumn = _find(columns, { index: selectedColIndex }); - - if (!selectedColumn || !anchorEle) return null; - - const menuActions = getFieldProp("contextMenuActions", selectedColumn.type); - const actionGroups: IContextMenuItem[][] = []; - - const actions = menuActions - ? menuActions(selectedCell, resetContextMenu) - : []; - if (actions.length > 0) actionGroups.push(actions); - - if (selectedColumn.type === FieldType.derivative) { - const renderedFieldMenuActions = getFieldProp( - "contextMenuActions", - selectedColumn.config.renderFieldType - ); - if (renderedFieldMenuActions) { - actionGroups.push( - renderedFieldMenuActions(selectedCell, resetContextMenu) - ); - } - } - - const row = tableState?.rows[selectedCell!.rowIndex]; - if (row) { - const rowActions = [ - { - label: "Copy link to row", - icon: , - onClick: () => { - const rowRef = encodeURIComponent(row.ref.path); - navigator.clipboard.writeText( - window.location.href + `?rowRef=${rowRef}` - ); - }, - }, - { - label: "Duplicate row", - icon: , - onClick: () => { - const { ref, ...clonedRow } = row; - addRow!(clonedRow, undefined, { type: "smaller" }); - resetContextMenu(); - }, - }, - { - label: "Delete row…", - color: "error", - icon: , - onClick: () => { - requestConfirmation({ - title: "Delete row?", - customBody: ( - <> - Row path: -
    - - {row.ref.path} - - - ), - confirm: "Delete", - confirmColor: "error", - handleConfirm: () => deleteRow?.(row.ref), - }); - resetContextMenu(); - }, - }, - ]; - actionGroups.push(rowActions); - } - - return ( - - ); -} diff --git a/src/components/Table/EmptyTable.tsx b/src/components/Table/EmptyTable.tsx deleted file mode 100644 index 2787abc8e..000000000 --- a/src/components/Table/EmptyTable.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { Grid, Stack, Typography, Button, Divider } from "@mui/material"; -import ImportIcon from "@src/assets/icons/Import"; -import AddColumnIcon from "@src/assets/icons/AddColumn"; - -import { APP_BAR_HEIGHT } from "@src/components/Navigation"; - -import { useProjectContext } from "@src/contexts/ProjectContext"; -import ColumnMenu from "./ColumnMenu"; -import ImportWizard from "@src/components/Wizards/ImportWizard"; -import ImportCSV from "@src/components/TableHeader/ImportCsv"; - -export default function EmptyTable() { - const { tableState, importWizardRef, columnMenuRef } = useProjectContext(); - - let contents = <>; - - if (tableState?.rows && tableState!.rows.length > 0) { - contents = ( - <> -
    - - Get started - - - There is existing data in the Firestore collection: -
    - {tableState?.tablePath} -
    -
    - -
    - - You can import that existing data to this table. - - - - - -
    - - ); - } else { - contents = ( - <> -
    - - Get started - - - There is no data in the Firestore collection: -
    - {tableState?.tablePath} -
    -
    - - - - - You can import data from an external CSV file: - - - ( - - )} - PopoverProps={{ - anchorOrigin: { - vertical: "bottom", - horizontal: "center", - }, - transformOrigin: { - vertical: "top", - horizontal: "center", - }, - }} - /> - - - - - or - - - - - - You can manually add new columns and rows: - - - - - - - - - ); - } - - return ( - - {contents} - - ); -} diff --git a/src/components/Table/FinalColumnHeader.tsx b/src/components/Table/FinalColumnHeader.tsx deleted file mode 100644 index 5e5f0b7ea..000000000 --- a/src/components/Table/FinalColumnHeader.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Column } from "react-data-grid"; - -import { Button } from "@mui/material"; -import AddColumnIcon from "@src/assets/icons/AddColumn"; - -import { useAppContext } from "@src/contexts/AppContext"; -import { useProjectContext } from "@src/contexts/ProjectContext"; - -const FinalColumnHeader: Column["headerRenderer"] = ({ column }) => { - const { userClaims } = useAppContext(); - const { columnMenuRef } = useProjectContext(); - if (!columnMenuRef) return null; - - if (!userClaims?.roles.includes("ADMIN")) return null; - - const handleClick = ( - event: React.MouseEvent - ) => - columnMenuRef?.current?.setSelectedColumnHeader({ - column, - anchorEl: event.currentTarget, - }); - - return ( - - ); -}; - -export default FinalColumnHeader; diff --git a/src/components/Table/HotKeys.tsx b/src/components/Table/HotKeys.tsx deleted file mode 100644 index c359fd55b..000000000 --- a/src/components/Table/HotKeys.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import useHotkeys from "../../hooks/useHotkeys"; -import { FieldType } from "@src/constants/fields"; -import { useAppContext } from "@src/contexts/AppContext"; - -// TODO: Hook up to ProjectContext -const onSubmit: any = () => () => {}; - -/** - * Listens Hot Keys combination keys to trigger keyboard shortcuts - */ -const Hotkeys = (props: any) => { - const { selectedCell } = props; - const { currentUser } = useAppContext(); - - useHotkeys( - "cmd+c", - () => { - handleCopy(); - }, - [selectedCell] - ); - useHotkeys( - "ctrl+c", - () => { - handleCopy(); - }, - [selectedCell] - ); - useHotkeys( - "cmd+v", - () => { - handlePaste(); - }, - [selectedCell] - ); - useHotkeys( - "ctrl+v", - () => { - handlePaste(); - }, - [selectedCell] - ); - useHotkeys( - "ctrl+x", - () => { - handleCut(); - }, - [selectedCell] - ); - useHotkeys( - "cmd+x", - () => { - handleCut(); - }, - [selectedCell] - ); - const stringFields = [ - FieldType.email, - FieldType.shortText, - FieldType.phone, - FieldType.singleSelect, - FieldType.longText, - FieldType.url, - ]; - const numberFields = [FieldType.number, FieldType.rating]; - /** - * populate cell from clipboard - */ - const handlePaste = async () => { - const { row, column } = selectedCell; - const newValue = await navigator.clipboard.readText(); - if (stringFields.includes(column.type)) - onSubmit(column.key, row, currentUser?.uid)(newValue); - else if (numberFields.includes(column.type)) { - const numberValue = parseInt(newValue, 10); - if (`${numberValue}` !== "NaN") { - onSubmit(column.key, row, currentUser?.uid)(numberValue); - } - } - }; - const supportedFields = [...stringFields, ...numberFields]; - /** - * copy cell content to clipboard works only on supported fields - */ - const handleCopy = () => { - const { row, column } = selectedCell; - if (supportedFields.includes(column.type)) { - navigator.clipboard.writeText(row[column.key]); - } - }; - /** - * copy cell content to clipboard and clears cell(only on supported fields) - */ - const handleCut = () => { - const { row, column } = selectedCell; - if (supportedFields.includes(column.type)) { - navigator.clipboard.writeText(row[column.key]); - onSubmit(column.key, row)(null); - } - }; - return <>; -}; -export default Hotkeys; diff --git a/src/components/Table/OutOfOrderIndicator.tsx b/src/components/Table/OutOfOrderIndicator.tsx deleted file mode 100644 index 0f3ece9e4..000000000 --- a/src/components/Table/OutOfOrderIndicator.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import createPersistedState from "use-persisted-state"; - -import { styled } from "@mui/material/styles"; -import RichTooltip from "@src/components/RichTooltip"; -import WarningIcon from "@mui/icons-material/WarningAmber"; -import { OUT_OF_ORDER_MARGIN } from "./TableContainer"; - -const useOutOfOrderTooltipDismissedState = createPersistedState( - "__ROWY__OUT_OF_ORDER_TOOLTIP_DISMISSED" -); - -const Dot = styled("div")(({ theme }) => ({ - position: "absolute", - left: -6, - top: "50%", - transform: "translateY(-50%)", - zIndex: 1, - - width: 12, - height: 12, - - borderRadius: "50%", - backgroundColor: theme.palette.warning.main, -})); - -export interface IOutOfOrderIndicatorProps { - top: number; - height: number; -} - -export default function OutOfOrderIndicator({ - top, - height, -}: IOutOfOrderIndicatorProps) { - const [dismissed, setDismissed] = useOutOfOrderTooltipDismissedState(false); - - return ( -
    - } - title="Row out of order" - message="This row will not appear on the top of the table after you reload this page" - placement="right" - render={({ openTooltip }) => } - defaultOpen={!dismissed} - onClose={() => setDismissed(true)} - /> -
    - ); -} diff --git a/src/components/Table/Skeleton/TableHeaderSkeleton.tsx b/src/components/Table/Skeleton/TableHeaderSkeleton.tsx index e6f12ecbb..02708be9f 100644 --- a/src/components/Table/Skeleton/TableHeaderSkeleton.tsx +++ b/src/components/Table/Skeleton/TableHeaderSkeleton.tsx @@ -1,11 +1,16 @@ -import { Fade, Stack, Button } from "@mui/material"; -import Skeleton from "@mui/material/Skeleton"; +import { Fade, Stack, Button, Skeleton, SkeletonProps } from "@mui/material"; import AddRowIcon from "@src/assets/icons/AddRow"; -import { TABLE_HEADER_HEIGHT } from "@src/components/TableHeader"; +// TODO: +// import { TABLE_HEADER_HEIGHT } from "@src/components/TableHeader"; +const TABLE_HEADER_HEIGHT = 44; -const ButtonSkeleton = (props) => ( - +const ButtonSkeleton = (props: Partial) => ( + ); export default function TableHeaderSkeleton() { diff --git a/src/components/Table/TableContainer.tsx b/src/components/Table/TableContainer.tsx deleted file mode 100644 index 3b67d858a..000000000 --- a/src/components/Table/TableContainer.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { styled, alpha, darken, lighten } from "@mui/material"; -import { APP_BAR_HEIGHT } from "@src/components/Navigation"; -import { DRAWER_COLLAPSED_WIDTH } from "@src/components/SideDrawer"; - -import { colord, extend } from "colord"; -import mixPlugin from "colord/plugins/lch"; -extend([mixPlugin]); - -export const OUT_OF_ORDER_MARGIN = 8; - -export const TableContainer = styled("div", { - shouldForwardProp: (prop) => prop !== "rowHeight", -})<{ rowHeight: number }>(({ theme, rowHeight }) => ({ - display: "flex", - flexDirection: "column", - height: `calc(100vh - ${APP_BAR_HEIGHT}px)`, - - "& > .rdg": { - width: `calc(100% - ${DRAWER_COLLAPSED_WIDTH}px)`, - flex: 1, - paddingBottom: `max(env(safe-area-inset-bottom), ${theme.spacing(2)})`, - }, - - [theme.breakpoints.down("sm")]: { width: "100%" }, - - "& .rdg": { - "--color": theme.palette.text.primary, - "--border-color": theme.palette.divider, - // "--summary-border-color": "#aaa", - "--background-color": - theme.palette.mode === "light" - ? theme.palette.background.paper - : colord(theme.palette.background.paper) - .mix("#fff", 0.04) - .alpha(1) - .toHslString(), - "--header-background-color": theme.palette.background.default, - "--row-hover-background-color": colord(theme.palette.background.paper) - .mix(theme.palette.action.hover, theme.palette.action.hoverOpacity) - .alpha(1) - .toHslString(), - "--row-selected-background-color": - theme.palette.mode === "light" - ? lighten(theme.palette.primary.main, 0.9) - : darken(theme.palette.primary.main, 0.8), - "--row-selected-hover-background-color": - theme.palette.mode === "light" - ? lighten(theme.palette.primary.main, 0.8) - : darken(theme.palette.primary.main, 0.7), - "--checkbox-color": theme.palette.primary.main, - "--checkbox-focus-color": theme.palette.primary.main, - "--checkbox-disabled-border-color": "#ccc", - "--checkbox-disabled-background-color": "#ddd", - "--selection-color": theme.palette.primary.main, - "--font-size": "0.75rem", - "--cell-padding": theme.spacing(0, 1.25), - - border: "none", - backgroundColor: "transparent", - - ...(theme.typography.caption as any), - // fontSize: "0.8125rem", - lineHeight: "inherit !important", - - "& .rdg-cell": { - display: "flex", - alignItems: "center", - padding: 0, - - overflow: "visible", - contain: "none", - position: "relative", - - lineHeight: "calc(var(--row-height) - 1px)", - }, - - "& .rdg-cell-frozen": { - position: "sticky", - }, - "& .rdg-cell-frozen-last": { - boxShadow: theme.shadows[2] - .replace(/, 0 (\d+px)/g, ", $1 0") - .split("),") - .slice(1) - .join("),"), - - "&[aria-selected=true]": { - boxShadow: - theme.shadows[2] - .replace(/, 0 (\d+px)/g, ", $1 0") - .split("),") - .slice(1) - .join("),") + ", inset 0 0 0 2px var(--selection-color)", - }, - }, - - "& .rdg-cell-copied": { - backgroundColor: - theme.palette.mode === "light" - ? lighten(theme.palette.primary.main, 0.7) - : darken(theme.palette.primary.main, 0.6), - }, - }, - - ".rdg-row, .rdg-header-row": { - marginLeft: `max(env(safe-area-inset-left), ${theme.spacing(2)})`, - marginRight: `max(env(safe-area-inset-right), ${theme.spacing(8)})`, - display: "inline-grid", // Fix Safari not showing margin-right - }, - - ".rdg-header-row .rdg-cell:first-child": { - borderTopLeftRadius: theme.shape.borderRadius, - }, - ".rdg-header-row .rdg-cell:last-child": { - borderTopRightRadius: theme.shape.borderRadius, - }, - - ".rdg-header-row .rdg-cell.final-column-header": { - border: "none", - padding: theme.spacing(0, 0.75), - borderBottomRightRadius: theme.shape.borderRadius, - - display: "flex", - alignItems: "center", - justifyContent: "flex-start", - - position: "relative", - "&::before": { - content: "''", - display: "block", - width: 88, - height: "100%", - - position: "absolute", - top: 0, - left: 0, - - border: "1px solid var(--border-color)", - borderLeftWidth: 0, - borderTopRightRadius: theme.shape.borderRadius, - borderBottomRightRadius: theme.shape.borderRadius, - }, - }, - - ".rdg-row .rdg-cell:first-child, .rdg-header-row .rdg-cell:first-child": { - borderLeft: "1px solid var(--border-color)", - }, - - ".rdg-row:last-child": { - borderBottomLeftRadius: theme.shape.borderRadius, - borderBottomRightRadius: theme.shape.borderRadius, - - "& .rdg-cell:first-child": { - borderBottomLeftRadius: theme.shape.borderRadius, - }, - "& .rdg-cell:nth-last-child(2)": { - borderBottomRightRadius: theme.shape.borderRadius, - }, - }, - - ".rdg-header-row .rdg-cell": { - borderTop: "1px solid var(--border-color)", - }, - - ".rdg-row:hover": { color: theme.palette.text.primary }, - - ".row-hover-iconButton": { - color: theme.palette.text.disabled, - transitionDuration: "0s", - }, - ".rdg-row:hover .row-hover-iconButton": { - color: theme.palette.text.primary, - backgroundColor: alpha( - theme.palette.action.hover, - theme.palette.action.hoverOpacity * 1.5 - ), - }, - - ".cell-collapse-padding": { - margin: theme.spacing(0, -1.25), - width: `calc(100% + ${theme.spacing(1.25 * 2)})`, - }, - - ".rdg-row.out-of-order": { - "--row-height": rowHeight + 1 + "px !important", - marginTop: -1, - marginBottom: OUT_OF_ORDER_MARGIN, - borderBottomLeftRadius: theme.shape.borderRadius, - - "& .rdg-cell:not(:last-child)": { - borderTop: `1px solid var(--border-color)`, - }, - "& .rdg-cell:first-child": { - borderBottomLeftRadius: theme.shape.borderRadius, - }, - "& .rdg-cell:nth-last-child(2)": { - borderBottomRightRadius: theme.shape.borderRadius, - }, - "&:not(:nth-child(4))": { - borderTopLeftRadius: theme.shape.borderRadius, - - "& .rdg-cell:first-child": { - borderTopLeftRadius: theme.shape.borderRadius, - }, - "& .rdg-cell:nth-last-child(2)": { - borderTopRightRadius: theme.shape.borderRadius, - }, - }, - - "& + .rdg-row:not(.out-of-order)": { - "--row-height": rowHeight + 1 + "px !important", - marginTop: -1, - borderTopLeftRadius: theme.shape.borderRadius, - - "& .rdg-cell:not(:last-child)": { - borderTop: `1px solid var(--border-color)`, - }, - "& .rdg-cell:first-child": { - borderTopLeftRadius: theme.shape.borderRadius, - }, - "& .rdg-cell:nth-last-child(2)": { - borderTopRightRadius: theme.shape.borderRadius, - }, - }, - }, -})); - -export default TableContainer; diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx deleted file mode 100644 index 29d6b15ef..000000000 --- a/src/components/Table/TableRow.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { useSetAnchorEle } from "@src/atoms/ContextMenu"; -import { Fragment } from "react"; -import { Row, RowRendererProps } from "react-data-grid"; - -import OutOfOrderIndicator from "./OutOfOrderIndicator"; - -export default function TableRow(props: RowRendererProps) { - const { setAnchorEle } = useSetAnchorEle(); - const handleContextMenu = ( - e: React.MouseEvent - ) => { - e.preventDefault(); - setAnchorEle?.(e?.target as HTMLElement); - }; - if (props.row._rowy_outOfOrder) - return ( - - - - - ); - - return ; -} diff --git a/src/components/Table/editors/NullEditor.tsx b/src/components/Table/editors/NullEditor.tsx deleted file mode 100644 index 9a9c3ebcb..000000000 --- a/src/components/Table/editors/NullEditor.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; -import { EditorProps } from "react-data-grid"; -// import _findIndex from "lodash/findIndex"; - -import { withStyles, WithStyles } from "@mui/styles"; -import styles from "./styles"; - -/** - * Allow the cell to be editable, but disable react-data-grid’s default - * text editor to show. - * - * Hides the editor container so the cell below remains editable inline. - * - * Use for cells that have inline editing and don’t need to be double-clicked. - * - * TODO: fix NullEditor overwriting the formatter component - */ -class NullEditor extends React.Component< - EditorProps & WithStyles -> { - getInputNode = () => null; - getValue = () => null; - render = () => null; -} - -export default withStyles(styles)(NullEditor); diff --git a/src/components/Table/editors/TextEditor.tsx b/src/components/Table/editors/TextEditor.tsx deleted file mode 100644 index b10e09e29..000000000 --- a/src/components/Table/editors/TextEditor.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useRef, useLayoutEffect } from "react"; -import { EditorProps } from "react-data-grid"; - -import { TextField } from "@mui/material"; - -import { FieldType } from "@src/constants/fields"; -import { getCellValue } from "@src/utils/fns"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { getColumnType } from "@src/components/fields"; - -export default function TextEditor({ row, column }: EditorProps) { - const { updateCell } = useProjectContext(); - - const type = getColumnType(column as any); - - const cellValue = getCellValue(row, column.key); - const defaultValue = - type === FieldType.percentage && typeof cellValue === "number" - ? cellValue * 100 - : cellValue; - - const inputRef = useRef(null); - - useLayoutEffect(() => { - return () => { - const newValue = inputRef.current?.value; - if (newValue !== undefined && updateCell) { - if (type === FieldType.number) { - updateCell(row.ref, column.key, Number(newValue)); - } else if (type === FieldType.percentage) { - updateCell(row.ref, column.key, Number(newValue) / 100); - } else { - updateCell(row.ref, column.key, newValue); - } - } - }; - }, []); - - let inputType = "text"; - switch (type) { - case FieldType.email: - inputType = "email"; - break; - case FieldType.phone: - inputType = "tel"; - break; - case FieldType.url: - inputType = "url"; - break; - case FieldType.number: - case FieldType.percentage: - inputType = "number"; - break; - - default: - break; - } - - const { maxLength } = (column as any).config; - - return ( - theme.typography.body2.lineHeight, - maxHeight: "100%", - boxSizing: "border-box", - py: 3 / 8, - }, - }} - InputProps={{ - endAdornment: - (column as any).type === FieldType.percentage ? "%" : undefined, - }} - autoFocus - onKeyDown={(e) => { - if (e.key === "ArrowLeft" || e.key === "ArrowRight") { - e.stopPropagation(); - } - - if (e.key === "Escape") { - (e.target as any).value = defaultValue; - } - }} - /> - ); -} diff --git a/src/components/Table/editors/styles.ts b/src/components/Table/editors/styles.ts deleted file mode 100644 index 0eb313151..000000000 --- a/src/components/Table/editors/styles.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createStyles } from "@mui/material"; - -export const styles = createStyles({ - "@global": { - ".rdg-editor-container": { display: "none" }, - }, -}); - -export default styles; diff --git a/src/components/Table/editors/withNullEditor.tsx b/src/components/Table/editors/withNullEditor.tsx deleted file mode 100644 index dda141163..000000000 --- a/src/components/Table/editors/withNullEditor.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { EditorProps } from "react-data-grid"; -import { IHeavyCellProps } from "@src/components/fields/types"; - -import { getCellValue } from "@src/utils/fns"; - -/** - * Allow the cell to be editable, but disable react-data-grid’s default - * text editor to show. - * - * Hides the editor container so the cell below remains editable inline. - * - * Use for cells that have inline editing and don’t need to be double-clicked. - */ -export default function withNullEditor( - HeavyCell?: React.ComponentType -) { - return function NullEditor(props: EditorProps) { - const { row, column } = props; - - return HeavyCell ? ( -
    - {}} - disabled={props.column.editable === false} - /> -
    - ) : null; - }; -} diff --git a/src/components/Table/editors/withSideDrawerEditor.tsx b/src/components/Table/editors/withSideDrawerEditor.tsx deleted file mode 100644 index 88322eecc..000000000 --- a/src/components/Table/editors/withSideDrawerEditor.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect } from "react"; -import { EditorProps } from "react-data-grid"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { IHeavyCellProps } from "@src/components/fields/types"; - -import { getCellValue } from "@src/utils/fns"; - -/** - * Allow the cell to be editable, but disable react-data-grid’s default - * text editor to show. Opens the side drawer in the appropriate position. - * - * Displays the current HeavyCell or HeavyCell since it overwrites cell contents. - * - * Use for cells that do not support any type of in-cell editing. - */ -export default function withSideDrawerEditor( - HeavyCell?: React.ComponentType -) { - return function SideDrawerEditor(props: EditorProps) { - const { row, column } = props; - const { sideDrawerRef } = useProjectContext(); - - useEffect(() => { - if (!sideDrawerRef?.current?.open && sideDrawerRef?.current?.setOpen) - sideDrawerRef?.current?.setOpen(true); - }, [column]); - - return HeavyCell ? ( -
    - {}} - disabled={props.column.editable === false} - /> -
    - ) : null; - }; -} diff --git a/src/components/Table/formatters/ChipList.tsx b/src/components/Table/formatters/ChipList.tsx deleted file mode 100644 index 5aaccab23..000000000 --- a/src/components/Table/formatters/ChipList.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Grid } from "@mui/material"; -import { useProjectContext } from "@src/contexts/ProjectContext"; - -export default function ChipList({ children }: React.PropsWithChildren<{}>) { - const { tableState } = useProjectContext(); - - const rowHeight = tableState?.config.rowHeight ?? 41; - const canWrap = rowHeight > 24 * 2 + 4; - - return ( - `calc(100% + ${theme.spacing(0.5)})`, - py: 0.5, - - "& .MuiChip-root": { - height: 24, - lineHeight: (theme) => theme.typography.caption.lineHeight, - font: "inherit", - letterSpacing: "inherit", - display: "flex", - cursor: "inherit", - }, - }} - > - {children} - - ); -} diff --git a/src/components/Table/formatters/FinalColumn.tsx b/src/components/Table/formatters/FinalColumn.tsx deleted file mode 100644 index 2d0347b29..000000000 --- a/src/components/Table/formatters/FinalColumn.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { FormatterProps } from "react-data-grid"; - -import { makeStyles, createStyles } from "@mui/styles"; -import { Stack, Tooltip, IconButton, alpha } from "@mui/material"; -import CopyCellsIcon from "@src/assets/icons/CopyCells"; -import DeleteIcon from "@mui/icons-material/DeleteOutlined"; - -import { useConfirmation } from "@src/components/ConfirmationDialog/Context"; -import { useAppContext } from "@src/contexts/AppContext"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import useKeyPress from "@src/hooks/useKeyPress"; -import { isCollectionGroup } from "@src/utils/fns"; - -const useStyles = makeStyles((theme) => - createStyles({ - "@global": { - ".final-column-cell": { - ".rdg.rdg .rdg-cell&": { - backgroundColor: "var(--header-background-color)", - borderColor: "var(--header-background-color)", - color: theme.palette.text.disabled, - padding: "var(--cell-padding)", - }, - }, - }, - }) -); - -export default function FinalColumn({ row }: FormatterProps) { - useStyles(); - - const { userClaims } = useAppContext(); - const { requestConfirmation } = useConfirmation(); - const { deleteRow, addRow, table } = useProjectContext(); - const altPress = useKeyPress("Alt"); - - const handleDelete = () => { - if (deleteRow) deleteRow(row.ref); - }; - - if (!userClaims?.roles.includes("ADMIN") && table?.readOnly === true) - return null; - return ( - - {!isCollectionGroup() && ( - - { - const { ref, ...clonedRow } = row; - addRow!(clonedRow, undefined, { type: "smaller" }); - }} - aria-label="Duplicate row" - className="row-hover-iconButton" - > - - - - )} - - - { - requestConfirmation({ - title: "Delete row?", - customBody: ( - <> - Row path: -
    - - {row.ref.path} - - - ), - confirm: "Delete", - confirmColor: "error", - handleConfirm: handleDelete, - }); - } - } - aria-label={`Delete row${altPress ? "" : "…"}`} - className="row-hover-iconButton" - sx={{ - ".rdg-row:hover &.row-hover-iconButton": { - color: "error.main", - backgroundColor: (theme) => - alpha( - theme.palette.error.main, - theme.palette.action.hoverOpacity * 2 - ), - }, - }} - > - -
    -
    -
    - ); -} diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx deleted file mode 100644 index 38f6724a3..000000000 --- a/src/components/Table/index.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import React, { useEffect, useRef, useMemo, useState } from "react"; -import _orderBy from "lodash/orderBy"; -import _find from "lodash/find"; -import _findIndex from "lodash/findIndex"; -import _difference from "lodash/difference"; -import _get from "lodash/get"; - -import { DndProvider } from "react-dnd"; -import { HTML5Backend } from "react-dnd-html5-backend"; - -// import "react-data-grid/dist/react-data-grid.css"; -import DataGrid, { - Column, - // SelectColumn as _SelectColumn, -} from "react-data-grid"; - -import Loading from "@src/components/Loading"; -import TableContainer, { OUT_OF_ORDER_MARGIN } from "./TableContainer"; -import TableHeader from "../TableHeader"; -import ColumnHeader from "./ColumnHeader"; -import ColumnMenu from "./ColumnMenu"; -import ContextMenu from "./ContextMenu"; -import FinalColumnHeader from "./FinalColumnHeader"; -import FinalColumn from "./formatters/FinalColumn"; -import TableRow from "./TableRow"; -import BulkActions from "./BulkActions"; - -import { getColumnType, getFieldProp } from "@src/components/fields"; -import { FieldType } from "@src/constants/fields"; -import { formatSubTableName } from "@src/utils/fns"; - -import { useAppContext } from "@src/contexts/AppContext"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import useWindowSize from "@src/hooks/useWindowSize"; -import { useSetSelectedCell } from "@src/atoms/ContextMenu"; - -export type TableColumn = Column & { - isNew?: boolean; - type: FieldType; - [key: string]: any; -}; - -const rowKeyGetter = (row: any) => row.id; -const rowClass = (row: any) => (row._rowy_outOfOrder ? "out-of-order" : ""); -//const SelectColumn = { ..._SelectColumn, width: 42, maxWidth: 42 }; - -export default function Table() { - const { - table, - tableState, - tableActions, - dataGridRef, - sideDrawerRef, - updateCell, - } = useProjectContext(); - const { userDoc, userClaims } = useAppContext(); - const { setSelectedCell } = useSetSelectedCell(); - - const userDocHiddenFields = - userDoc.state.doc?.tables?.[formatSubTableName(tableState?.config.id)] - ?.hiddenFields ?? []; - - const [columns, setColumns] = useState([]); - - useEffect(() => { - if (!tableState?.loadingColumns && tableState?.columns) { - const _columns = _orderBy( - Object.values(tableState?.columns).filter( - (column: any) => !column.hidden && column.key - ), - "index" - ) - .map((column: any) => ({ - draggable: true, - resizable: true, - frozen: column.fixed, - headerRenderer: ColumnHeader, - formatter: - getFieldProp("TableCell", getColumnType(column)) ?? - function InDev() { - return null; - }, - editor: - getFieldProp("TableEditor", getColumnType(column)) ?? - function InDev() { - return null; - }, - ...column, - editable: - table?.readOnly && !userClaims?.roles.includes("ADMIN") - ? false - : column.editable ?? true, - width: (column.width as number) - ? (column.width as number) > 380 - ? 380 - : (column.width as number) - : 150, - })) - .filter((column) => !userDocHiddenFields.includes(column.key)); - - if (!table?.readOnly || userClaims?.roles.includes("ADMIN")) { - _columns.push({ - isNew: true, - key: "new", - name: "Add column", - type: FieldType.last, - index: _columns.length ?? 0, - width: 154, - headerRenderer: FinalColumnHeader, - headerCellClass: "final-column-header", - cellClass: "final-column-cell", - formatter: FinalColumn, - editable: false, - }); - } - - setColumns(_columns); - - // setColumns([ - // // SelectColumn, - // ..._columns, - // , - // ]); - } - }, [ - tableState?.loadingColumns, - tableState?.columns, - JSON.stringify(userDocHiddenFields), - table?.readOnly, - userClaims?.roles, - ]); - - const rows = - useMemo( - () => - tableState?.rows.map((row) => - columns.reduce( - (acc, currColumn) => { - if (currColumn.key.includes(".")) { - return { - ...acc, - [currColumn.key]: _get(row, currColumn.key), - }; - } else return acc; - }, - { ...row, id: row.id as string, ref: row.ref } - ) - ), - [columns, tableState?.rows] - ) ?? []; - - const rowsContainerRef = useRef(null); - const [selectedRowsSet, setSelectedRowsSet] = useState>(); - const [selectedRows, setSelectedRows] = useState([]); - // Gets more rows when scrolled down. - // https://github.com/adazzle/react-data-grid/blob/ead05032da79d7e2b86e37cdb9af27f2a4d80b90/stories/demos/AllFeatures.tsx#L60 - const handleScroll = (event: React.UIEvent) => { - const target = event.target as HTMLDivElement; - const offset = 800; - const isAtBottom = - target.clientHeight + target.scrollTop >= target.scrollHeight - offset; - - if (!isAtBottom) return; - - // Prevent calling more rows when they’ve already been called - if (tableState!.loadingRows) return; - - // Call for 30 more rows. Note we don’t know here if there are no more - // rows left in the database. This is done in the useTable hook. - tableActions?.row.more(30); - }; - - const windowSize = useWindowSize(); - if (!windowSize || !windowSize.height) return <>; - - if (!tableActions || !tableState) return <>; - - const rowHeight = tableState.config.rowHeight ?? 42; - - return ( - <> - {/* }> - - */} - - - - {!tableState.loadingColumns ? ( - - { - if (row._rowy_outOfOrder) - return rowHeight + OUT_OF_ORDER_MARGIN + 1; - - return rowHeight; - }} - headerRowHeight={42} - className="rdg-light" // Handle dark mode in MUI theme - cellNavigationMode="LOOP_OVER_ROW" - rowRenderer={TableRow} - rowKeyGetter={rowKeyGetter} - rowClass={rowClass} - selectedRows={selectedRowsSet} - onSelectedRowsChange={(newSelectedSet) => { - const newSelectedArray = newSelectedSet - ? [...newSelectedSet] - : []; - const prevSelectedRowsArray = selectedRowsSet - ? [...selectedRowsSet] - : []; - const addedSelections = _difference( - newSelectedArray, - prevSelectedRowsArray - ); - const removedSelections = _difference( - prevSelectedRowsArray, - newSelectedArray - ); - addedSelections.forEach((id) => { - const newRow = _find(rows, { id }); - setSelectedRows([...selectedRows, newRow]); - }); - removedSelections.forEach((rowId) => { - setSelectedRows( - selectedRows.filter((row) => row.id !== rowId) - ); - }); - setSelectedRowsSet(newSelectedSet); - }} - // onRowsChange={() => { - //console.log('onRowsChange',rows) - // }} - // TODO: onFill={(e) => { - // console.log("onFill", e); - // const { columnKey, sourceRow, targetRows } = e; - // if (updateCell) - // targetRows.forEach((row) => - // updateCell(row.ref, columnKey, sourceRow[columnKey]) - // ); - // return []; - // }} - onPaste={(e) => { - const copiedValue = e.sourceRow[e.sourceColumnKey]; - if (updateCell) { - updateCell(e.targetRow.ref, e.targetColumnKey, copiedValue); - } - }} - onRowClick={(row, column) => { - if (sideDrawerRef?.current) { - sideDrawerRef.current.setCell({ - row: _findIndex(tableState.rows, { id: row.id }), - column: column.key, - }); - } - }} - onSelectedCellChange={({ rowIdx, idx }) => - setSelectedCell({ - rowIndex: rowIdx, - colIndex: idx, - }) - } - /> - - ) : ( - - )} - - - - - { - setSelectedRowsSet(new Set()); - setSelectedRows([]); - }} - /> - - ); -} diff --git a/src/components/TableHeader/AddRow.tsx b/src/components/TableHeader/AddRow.tsx deleted file mode 100644 index bc6bfa5d4..000000000 --- a/src/components/TableHeader/AddRow.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useState, useRef } from "react"; -import createPersistedState from "use-persisted-state"; -import { FieldType, FormDialog } from "@rowy/form-builder"; - -import { - Button, - ButtonGroup, - Select, - MenuItem, - ListItemText, - Box, -} from "@mui/material"; -import AddRowIcon from "@src/assets/icons/AddRow"; -import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; - -import { useAppContext } from "@src/contexts/AppContext"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { isCollectionGroup } from "@src/utils/fns"; -import { db } from "@src/firebase"; - -const useIdTypeState = createPersistedState("__ROWY__ADD_ROW_ID_TYPE"); - -export default function AddRow() { - const { userClaims } = useAppContext(); - const { addRow, table, tableState } = useProjectContext(); - - const anchorEl = useRef(null); - const [open, setOpen] = useState(false); - const [idType, setIdType] = useIdTypeState<"smaller" | "random" | "custom">( - "smaller" - ); - const [openIdModal, setOpenIdModal] = useState(false); - - const handleClick = () => { - if (idType === "smaller") { - addRow!(undefined, undefined, { type: "smaller" }); - } else if (idType === "random") { - addRow!(); - } else if (idType === "custom") { - setOpenIdModal(true); - } - }; - - if (table?.readOnly && !userClaims?.roles.includes("ADMIN")) - return ; - - return ( - <> - - - - - - - - - {openIdModal && ( - - value && - ( - await db - .collection(tableState!.tablePath!) - .doc(value) - .get() - ).exists === false, - ], - ], - }, - ]} - onSubmit={(v) => addRow!(undefined, undefined, v.id)} - onClose={() => setOpenIdModal(false)} - DialogProps={{ maxWidth: "xs" }} - SubmitButtonProps={{ children: "Add row" }} - /> - )} - - ); -} diff --git a/src/components/TableHeader/CloudLogs/BuildLogs/BuildLogList.tsx b/src/components/TableHeader/CloudLogs/BuildLogs/BuildLogList.tsx deleted file mode 100644 index 42f7bc4e8..000000000 --- a/src/components/TableHeader/CloudLogs/BuildLogs/BuildLogList.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useEffect, useRef } from "react"; -import useStateRef from "react-usestateref"; -import _throttle from "lodash/throttle"; - -import { Box } from "@mui/material"; - -import BuildLogRow from "./BuildLogRow"; -import CircularProgressOptical from "@src/components/CircularProgressOptical"; - -import { isTargetInsideBox } from "utils/fns"; - -export interface IBuildLogListProps - extends React.DetailedHTMLProps< - React.HTMLAttributes, - HTMLDivElement - > { - logs: Record[]; - status: string; - value: number; - index: number; -} - -export default function BuildLogList({ - logs, - status, - value, - index, - ...props -}: IBuildLogListProps) { - // useStateRef is necessary to resolve the state syncing issue - // https://stackoverflow.com/a/63039797/12208834 - const [liveStreaming, setLiveStreaming, liveStreamingStateRef] = - useStateRef(true); - const liveStreamingRef = useRef(); - const isActive = value === index; - - const handleScroll = _throttle(() => { - const target = document.querySelector("#live-stream-target"); - const scrollBox = document.querySelector("#live-stream-scroll-box"); - const liveStreamTargetVisible = isTargetInsideBox(target, scrollBox); - if (liveStreamTargetVisible !== liveStreamingStateRef.current) { - setLiveStreaming(liveStreamTargetVisible); - } - }, 500); - - const scrollToLive = () => { - const liveStreamTarget = document.querySelector("#live-stream-target"); - liveStreamTarget?.scrollIntoView?.({ - behavior: "smooth", - }); - }; - - useEffect(() => { - if (liveStreaming && isActive && status === "BUILDING") { - if (!liveStreamingRef.current) { - scrollToLive(); - } else { - setTimeout(scrollToLive, 100); - } - } - }, [logs, value]); - - useEffect(() => { - if (isActive) { - const liveStreamScrollBox = document.querySelector( - "#live-stream-scroll-box" - ); - liveStreamScrollBox!.addEventListener("scroll", () => { - handleScroll(); - }); - } - }, [value]); - - return ( -