Merge branch 'v3' of https://github.com/notsidney/xtable into data-layer-rewrite

This commit is contained in:
Sidney Alcantara
2022-05-04 19:10:19 +10:00
parent 633bb6ba4e
commit 9d79d22b29
490 changed files with 10220 additions and 42950 deletions

1
.husky/pre-commit Executable file
View File

@@ -0,0 +1 @@
yarn lint-staged

View File

@@ -1,2 +1,3 @@
node_modules/
.yarn
emulators/

View File

@@ -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 doesnt 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;
},
},
};

View File

@@ -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}]}

View File

@@ -0,0 +1 @@
{"signIn":{"allowDuplicateEmails":false},"usageMode":"DEFAULT"}

View File

@@ -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"
}
}

View File

@@ -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
}
}
}

4
firestore.indexes.json Normal file
View File

@@ -0,0 +1,4 @@
{
"indexes": [],
"fieldOverrides": []
}

36
firestore.rules Normal file
View File

@@ -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);
}
}
}

View File

@@ -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"
}
}

View File

@@ -1,2 +0,0 @@
# Rewrite a path
/* /index.html 200

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#4200ff</TileColor>
</tile>
</msapplication>
</browserconfig>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,2 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -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"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 268 KiB

View File

@@ -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(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

View File

@@ -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 ImpersonatorAuthPage = lazy(() => import("@src/pages/Auth/ImpersonatorAuth" /* webpackChunkName: "ImpersonatorAuthPage" */));
// prettier-ignore
const HomePage = lazy(() => import("./pages/Home" /* webpackChunkName: "HomePage" */));
// prettier-ignore
const TablePage = lazy(() => import("./pages/Table" /* webpackChunkName: "TablePage" */));
// prettier-ignore
const ProjectSettingsPage = lazy(() => import("./pages/Settings/ProjectSettings" /* webpackChunkName: "ProjectSettingsPage" */));
// prettier-ignore
const UserSettingsPage = lazy(() => import("./pages/Settings/UserSettings" /* webpackChunkName: "UserSettingsPage" */));
// prettier-ignore
const UserManagementPage = lazy(() => import("./pages/Settings/UserManagement" /* webpackChunkName: "UserManagementPage" */));
// prettier-ignore
const SetupPage = lazy(() => import("@src/pages/Setup" /* webpackChunkName: "SetupPage" */));
// prettier-ignore
const TablesPage = lazy(() => import("@src/pages/Tables" /* webpackChunkName: "TablesPage" */));
// prettier-ignore
const TablePage = lazy(() => import("@src/pages/TableTest" /* webpackChunkName: "TablePage" */));
// prettier-ignore
const UserSettingsPage = lazy(() => import("@src/pages/Settings/UserSettings" /* webpackChunkName: "UserSettingsPage" */));
// prettier-ignore
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 (
<StyledEngineProvider injectFirst>
<ErrorBoundary>
<SwrProvider>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<AppProvider>
<Favicon />
<SnackbarProvider>
<ConfirmationProvider>
<SnackLogProvider>
<CustomBrowserRouter>
<RowyRunModal />
<Suspense fallback={<Loading fullScreen />}>
<Switch>
<Route
exact
path={routes.auth}
render={() => <AuthPage />}
/>
<Route
exact
path={routes.authSetup}
render={() => <AuthSetupGuidePage />}
/>
<Route
exact
path={routes.jwtAuth}
render={() => <JwtAuthPage />}
/>
<Route
exact
path={routes.signOut}
render={() => <SignOutPage />}
/>
<Route
exact
path={routes.signUp}
render={() => <SignUpPage />}
/>
<Route
exact
path={routes.setup}
render={() => <SetupPage />}
/>
<Suspense fallback={<Loading fullScreen />}>
<ProjectSourceFirebase />
<ConfirmDialog />
<RowyRunModal />
<Route
exact
path={"/test"}
render={() => <TestPage />}
/>
{currentUser === undefined ? (
<Loading fullScreen message="Authenticating" />
) : (
<Routes>
<Route path="*" element={<NotFound />} />
<PrivateRoute
exact
path={[
routes.home,
routes.tableWithId,
routes.tableGroupWithId,
routes.settings,
routes.projectSettings,
routes.userSettings,
routes.userManagement,
routes.impersonatorAuth,
routes.rowyRunTest,
]}
render={() => (
<ProjectContextProvider>
<Switch>
<Route
exact
path={routes.impersonatorAuth}
render={() => <ImpersonatorAuthPage />}
/>
<Route
exact
path={routes.rowyRunTest}
render={() => <RowyRunTestPage />}
/>
<PrivateRoute
exact
path={routes.home}
render={() => (
<Navigation
title="Home"
titleComponent={(open, pinned) =>
!(open && pinned) && (
<Logo
style={{
display: "block",
margin: "0 auto",
}}
/>
)
}
>
<HomePage />
</Navigation>
)}
/>
<PrivateRoute
path={routes.tableWithId}
render={() => <TablePage />}
/>
<PrivateRoute
path={routes.tableGroupWithId}
render={() => <TablePage />}
/>
<Route path={ROUTES.auth} element={<AuthPage />} />
<Route path={ROUTES.signUp} element={<SignUpPage />} />
<Route path={ROUTES.signOut} element={<SignOutPage />} />
<Route path={ROUTES.jwtAuth} element={<JwtAuthPage />} />
<Route
path={ROUTES.impersonatorAuth}
element={
<RequireAuth>
<ImpersonatorAuthPage />
</RequireAuth>
}
/>
<PrivateRoute
exact
path={routes.settings}
render={() => (
<Redirect to={routes.userSettings} />
)}
/>
<PrivateRoute
exact
path={routes.projectSettings}
render={() => (
<Navigation title="Project Settings">
<ProjectSettingsPage />
</Navigation>
)}
/>
<PrivateRoute
exact
path={routes.userSettings}
render={() => (
<Navigation title="Settings">
<UserSettingsPage />
</Navigation>
)}
/>
<PrivateRoute
exact
path={routes.userManagement}
render={() => (
<Navigation title="User Management">
<UserManagementPage />
</Navigation>
)}
/>
</Switch>
</ProjectContextProvider>
)}
/>
<Route path={ROUTES.setup} element={<SetupPage />} />
<Route
exact
path={routes.pageNotFound}
render={() => <PageNotFound />}
/>
<Route render={() => <PageNotFound />} />
</Switch>
</Suspense>
</CustomBrowserRouter>
</SnackLogProvider>
</ConfirmationProvider>
</SnackbarProvider>
</AppProvider>
</LocalizationProvider>
</SwrProvider>
</ErrorBoundary>
</StyledEngineProvider>
<Route
path="/"
element={
<RequireAuth>
<Navigation>
<TableSettingsDialog />
</Navigation>
</RequireAuth>
}
>
<Route
path={ROUTES.home}
element={<Navigate to={ROUTES.tables} replace />}
/>
<Route path={ROUTES.tables} element={<TablesPage />} />
<Route path={ROUTES.table}>
<Route index element={<Navigate to={ROUTES.tables} replace />} />
<Route path=":id" element={<TablePage />} />
</Route>
<Route
path={ROUTES.settings}
element={<Navigate to={ROUTES.userSettings} replace />}
/>
<Route path={ROUTES.userSettings} element={<UserSettingsPage />} />
<Route
path={ROUTES.projectSettings}
element={<ProjectSettingsPage />}
/>
<Route
path={ROUTES.userManagement}
element={<UserManagementPage />}
/>
{/* <Route path={ROUTES.rowyRunTest} element={<RowyRunTestPage />} /> */}
<Route path="/jotaiTest" element={<JotaiTestPage />} />
</Route>
{/* <Route path="/jotaiTest" element={<JotaiTestPage />} /> */}
</Routes>
)}
</Suspense>
);
}

47
src/Providers.tsx Normal file
View File

@@ -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<readonly [Atom<unknown>, unknown]>;
}
export default function Providers({
children,
initialAtomValues,
}: IProvidersProps) {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<BrowserRouter>
<HelmetProvider>
<Provider scope={globalScope} initialValues={initialAtomValues}>
<CacheProvider value={muiCache}>
<RowyThemeProvider>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<SnackbarProvider>
<Suspense fallback={<Loading fullScreen />}>
{children}
</Suspense>
</SnackbarProvider>
</ErrorBoundary>
</RowyThemeProvider>
</CacheProvider>
</Provider>
</HelmetProvider>
</BrowserRouter>
</ErrorBoundary>
);
}

View File

@@ -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 };

View File

@@ -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 (
<Helmet>
<style type="text/css">
{`
<GlobalStyles
styles={(theme) => `
body {
background-size: 100%;
background-image: ${
@@ -44,8 +40,7 @@ export default function BrandedBackground() {
mix-blend-mode: overlay;
}
`}
</style>
</Helmet>
/>
);
}

View File

@@ -24,14 +24,14 @@ export default function LogoRowyRun({
<title id="rowy-run-logo-title">Rowy Run</title>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M32 7.75a6.25 6.25 0 1 1 0 12.5 6.25 6.25 0 0 1 0-12.5Zm0 2a4.25 4.25 0 1 0 0 8.5 4.25 4.25 0 0 0 0-8.5ZM20 20V8h6v2h-4v10h-2Zm24 0 3-9 3 9h2l4-12 5 11.5-2 4.5h2l7-16h-2l-4 9-4-9h-4l-3 9-3-9h-2l-3 9-3-9h-2l4 12h2Z"
fill={theme.palette.text.primary}
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M0 8v10a3 3 0 1 0 6 0v-7h7a3 3 0 1 0 0-6H8a2.997 2.997 0 0 0-2.5 1.341A3 3 0 0 0 0 8Zm10-2H8a2 2 0 0 0-1.995 1.85L6 8v2h4V6Zm-5 4V8a2 2 0 0 0-1.85-1.995L3 6a2 2 0 0 0-1.995 1.85L1 8v2h4Zm0 1H1v4h4v-4Zm-4 5v2a2 2 0 0 0 1.85 1.994L3 20a2 2 0 0 0 1.995-1.85L5 18v-2H1ZM11.001 6H13l.15.005A2 2 0 0 1 15 8l-.005.15A2 2 0 0 1 13 10h-1.999V6Z"
fill={theme.palette.primary.main}
/>

5
src/assets/favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg">
<path
d="M13 0a3 3 0 010 6l-2-.001V6H6v7a3 3 0 01-6 0V3a3 3 0 015.501-1.657A2.989 2.989 0 018 0h5zM5 11H1v2a2 2 0 001.85 1.995L3 15a2 2 0 001.995-1.85L5 13v-2zm0-5H1v4h4V6zM3 1a2 2 0 00-1.995 1.85L1 3v2h4V3a2 2 0 00-1.85-1.995L3 1zm8.001 0v4H13a2 2 0 001.995-1.85L15 3a2 2 0 00-1.85-1.995L13 1h-1.999zM10 1H8a2 2 0 00-1.995 1.85L6 3v2h4V1z"
fill="currentColor" fill-rule="nonzero" />
</svg>

After

Width:  |  Height:  |  Size: 465 B

View File

@@ -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 (

View File

@@ -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<SelectedCell | null>(null);
const anchorEleAtom = atomWithReset<HTMLElement | null>(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,
};
}

View File

@@ -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 });
};

View File

@@ -1,5 +0,0 @@
import { atomWithHash } from "jotai/utils";
export const modalAtom = atomWithHash<
"cloudLogs" | "extensions" | "webhooks" | "export" | ""
>("modal", "");

View File

@@ -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<User | null | undefined>(undefined);
/** User roles from Firebase Auth user custom claims */
export const userRolesAtom = atom<string[]>([]);

View File

@@ -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";

View File

@@ -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<string>("");
/** 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<PublicSettings>({});
/** Stores a function that updates public settings */
export const updatePublicSettingsAtom =
atom<UpdateDocFunction<PublicSettings> | 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<ProjectSettings>({});
/** Stores a function that updates project settings */
export const updateProjectSettingsAtom =
atom<UpdateDocFunction<ProjectSettings> | null>(null);
/** Tables visible to the signed-in user based on roles */
export const tablesAtom = atom<TableSettings[]>((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<UserSettings[]>([]);
/** Stores a function that updates a user document */
export const updateUserAtom =
atom<UpdateCollectionFunction<UserSettings> | null>(null);

View File

@@ -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 thats 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<Response | any | false> => {
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<string, any>;
rowyRun: null | Record<string, any>;
deployedRowy: string;
deployedRowyRun: string;
};
/** Store latest update from GitHub releases and currently deployed versions */
export const rowyRunLatestUpdateAtom = atomWithStorage<RowyRunLatestUpdate>(
"__ROWY__UPDATE_CHECK",
{
lastChecked: "",
rowy: null,
rowyRun: null,
deployedRowy: meta.version,
deployedRowyRun: "",
}
);

126
src/atoms/globalScope/ui.ts Normal file
View File

@@ -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<ConfirmDialogProps>) => {
set(confirmDialogAtom, {
...get(confirmDialogAtom),
open: true, // Dont 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<RowyRunModalState>) => {
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<TableSettingsDialogState>) => {
set(tableSettingsDialogAtom, {
open: true,
mode: "create",
data: null,
...update,
});
}
);

View File

@@ -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<UserSettings>({});
/** Stores a function that updates user settings */
export const updateUserSettingsAtom =
atom<UpdateDocFunction<UserSettings> | 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),
};
});

View File

@@ -0,0 +1,4 @@
/** Scope for atoms stored at the table level */
export const tableScope = Symbol("tableScope");
export * from "./table";

View File

@@ -0,0 +1,18 @@
import { atom } from "jotai";
import {
TableSettings,
TableSchema,
TableFilter,
TableOrder,
} from "@src/types/table";
export const tableIdAtom = atom<string | undefined>(undefined);
export const tableSettingsAtom = atom<TableSettings | undefined>(undefined);
export const tableSchemaAtom = atom<TableSchema | undefined>(undefined);
export const tableFiltersAtom = atom<TableFilter[]>([]);
export const tableOrdersAtom = atom<TableOrder[]>([]);
export const tablePageAtom = atom(0);
export const tableRowsAtom = atom<Record<string, any>[]>([]);
export const tableLoadingMoreAtom = atom(false);

View File

@@ -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 (
<EmptyState
fullScreen
Icon={SecurityIcon}
message="Access denied"
description={
<>
<div style={{ textAlign: "left", width: "100%" }}>
<Stack
direction="row"
spacing={1.25}
alignItems="flex-start"
sx={{ mt: 2 }}
>
<Avatar src={currentUser?.photoURL || ""} />
<div>
<Typography>{currentUser?.displayName}</Typography>
<Typography variant="button">{currentUser?.email}</Typography>
</div>
</Stack>
{(!userRoles || userRoles.length === 0) && (
<Alert severity="warning" sx={{ mt: 2 }}>
Your account has no roles set
</Alert>
)}
</div>
<Typography>
You do not have access to this project. Please contact the project
owner.
</Typography>
<Button href={ROUTES.signOut}>Sign out</Button>
<Divider flexItem sx={{ typography: "overline" }}>
OR
</Divider>
<Typography>
If you are the project owner, please follow{" "}
<MuiLink
href={WIKI_LINKS.setupRoles}
target="_blank"
rel="noopener noreferrer"
>
these instructions
</MuiLink>{" "}
to set up this projects security rules.
</Typography>
</>
}
sx={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: "background.default",
zIndex: 9999,
}}
/>
);
}

View File

@@ -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<FirebaseUiProps>) {
const classes = useStyles();
const [signInOptions, setSignInOptions] = useState<
Parameters<typeof getSignInOptions>[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 (
<>
<Typography variant="button" className={classes.signInText}>
Continue with
</Typography>
<div id="rowy-firebaseui-skeleton" className={classes.skeleton}>
<Skeleton variant="rectangular" />
</div>
</>
);
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 (
<>
<Typography variant="button" className={classes.signInText}>
Continue with
</Typography>
<StyledFirebaseAuth
{...props}
firebaseAuth={auth}
uiConfig={uiConfig}
className={clsx("rowy-firebaseui", props.className)}
/>
</>
);
}

View File

@@ -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<HTMLButtonElement>
) {
const classes = useStyles();
return (
<Button
{...props}
ref={ref}
variant="outlined"
color={active ? "primary" : "secondary"}
className={clsx(classes.root, active && classes.active, className)}
/>
);
});
export default ButtonWithStatus;

View File

@@ -1,9 +1,11 @@
import { useAtom } from "jotai";
import { Stack, Typography, Grid, Tooltip, IconButton } from "@mui/material";
import SecretsIcon from "@mui/icons-material/VpnKeyOutlined";
import FunctionsIcon from "@mui/icons-material/CloudOutlined";
import DocsIcon from "@mui/icons-material/DescriptionOutlined";
import { useAppContext } from "@src/contexts/AppContext";
import { globalScope, projectIdAtom } from "@src/atoms/globalScope";
export interface ICodeEditorHelperProps {
docLink: string;
@@ -17,7 +19,8 @@ export default function CodeEditorHelper({
docLink,
additionalVariables,
}: ICodeEditorHelperProps) {
const { projectId } = useAppContext();
const [projectId] = useAtom(projectIdAtom, globalScope);
const availableVariables = [
{
key: "row",

View File

@@ -1,3 +1,4 @@
/* eslint-disable tsdoc/syntax */
type Trigger = "create" | "update" | "delete";
type Triggers = Trigger[];

View File

@@ -1,3 +1,4 @@
/* eslint-disable tsdoc/syntax */
/*! firebase-admin v8.11.0 */
/* eslint-disable @typescript-eslint/ban-types */

View File

@@ -1,3 +1,4 @@
/* eslint-disable tsdoc/syntax */
/* eslint-disable @typescript-eslint/ban-types */
// node_modules/@google-cloud/storage/build/src/bucket.d.ts

View File

@@ -1,3 +1,4 @@
/* eslint-disable tsdoc/syntax */
/**
* @fileoverview Firestore Server API.
*

View File

@@ -0,0 +1,2 @@
export * from "./CodeEditor";
export { default } from "./CodeEditor";

View File

@@ -13,8 +13,8 @@ import githubDarkTheme from "./github-dark-default.json";
import { useTheme } from "@mui/material";
import type { SystemStyleObject, Theme } from "@mui/system";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { getColumnType, getFieldProp } from "@src/components/fields";
// TODO:
// import { getColumnType, getFieldProp } from "@src/components/fields";
/* eslint-disable import/no-webpack-loader-syntax */
import firestoreDefs from "!!raw-loader!./firestore.d.ts";
@@ -23,7 +23,6 @@ import firebaseStorageDefs from "!!raw-loader!./firebaseStorage.d.ts";
import utilsDefs from "!!raw-loader!./utils.d.ts";
import rowyUtilsDefs from "!!raw-loader!./rowy.d.ts";
import extensionsDefs from "!!raw-loader!./extensions.d.ts";
import defaultValueDefs from "!!raw-loader!./defaultValue.d.ts";
import { runRoutes } from "@src/constants/runRoutes";
export interface IUseMonacoCustomizationsProps {
@@ -54,7 +53,6 @@ export default function useMonacoCustomizations({
fullScreen,
}: IUseMonacoCustomizationsProps) {
const theme = useTheme();
const { tableState, rowyRun } = useProjectContext();
const monaco = useMonaco();
@@ -141,111 +139,115 @@ export default function useMonacoCustomizations({
}
}, [monaco, stringifiedDiagnosticsOptions]);
const addJsonFieldDefinition = async (columnKey, interfaceName) => {
const samples = tableState?.rows
.map((row) => row[columnKey])
.filter((entry) => entry !== undefined)
.map((entry) => JSON.stringify(entry));
if (!samples || samples.length === 0) {
monaco?.languages.typescript.javascriptDefaults.addExtraLib(
`type ${interfaceName} = any;`
);
return;
} else {
const jsonInput = jsonInputForTargetLanguage("typescript");
await jsonInput.addSource({
name: interfaceName,
samples,
});
// TODO:
// const addJsonFieldDefinition = async (columnKey, interfaceName) => {
// const samples = tableState?.rows
// .map((row) => row[columnKey])
// .filter((entry) => entry !== undefined)
// .map((entry) => JSON.stringify(entry));
// if (!samples || samples.length === 0) {
// monaco?.languages.typescript.javascriptDefaults.addExtraLib(
// `type ${interfaceName} = any;`
// );
// return;
// } else {
// const jsonInput = jsonInputForTargetLanguage("typescript");
// await jsonInput.addSource({
// name: interfaceName,
// samples,
// });
const inputData = new InputData();
inputData.addInput(jsonInput);
const result = await quicktype({
inputData,
lang: "typescript",
rendererOptions: { "just-types": "true" },
});
const newLib = result.lines.join("\n").replaceAll("export ", "");
monaco?.languages.typescript.javascriptDefaults.addExtraLib(newLib);
}
};
// const inputData = new InputData();
// inputData.addInput(jsonInput);
// const result = await quicktype({
// inputData,
// lang: "typescript",
// rendererOptions: { "just-types": "true" },
// });
// const newLib = result.lines.join("\n").replaceAll("export ", "");
// monaco?.languages.typescript.javascriptDefaults.addExtraLib(newLib);
// }
// };
const setSecrets = async (monaco, rowyRun) => {
// set secret options
try {
const listSecrets = await rowyRun({
route: runRoutes.listSecrets,
});
const secretsDef = `type SecretNames = ${listSecrets
.map((secret) => `"${secret}"`)
.join(" | ")}
enum secrets {
${listSecrets.map((secret) => `${secret} = "${secret}"`).join("\n")}
}
`;
monaco.languages.typescript.javascriptDefaults.addExtraLib(secretsDef);
} catch (error) {
console.error("Could not set secret definitions: ", error);
}
};
const setBaseDefinitions = (monaco, columns) => {
const rowDefinition =
[
Object.keys(columns).map((columnKey: string) => {
const column = columns[columnKey];
const type = getColumnType(column);
if (type === "JSON") {
const interfaceName =
columnKey[0].toUpperCase() + columnKey.slice(1);
addJsonFieldDefinition(columnKey, interfaceName);
const def = `static "${columnKey}": ${interfaceName}`;
return def;
}
return `static "${columnKey}": ${getFieldProp("dataType", type)}`;
}),
].join(";\n") + ";";
// TODO: types
// const setSecrets = async (monaco, rowyRun) => {
// // set secret options
// try {
// const listSecrets = await rowyRun({
// route: runRoutes.listSecrets,
// });
// const secretsDef = `type SecretNames = ${listSecrets
// .map((secret) => `"${secret}"`)
// .join(" | ")}
// enum secrets {
// ${listSecrets.map((secret) => `${secret} = "${secret}"`).join("\n")}
// }
// `;
// monaco.languages.typescript.javascriptDefaults.addExtraLib(secretsDef);
// } catch (error) {
// console.error("Could not set secret definitions: ", error);
// }
// };
// TODO: types
// const setBaseDefinitions = (monaco, columns) => {
// const rowDefinition =
// [
// Object.keys(columns).map((columnKey: string) => {
// const column = columns[columnKey];
// const type = getColumnType(column);
// if (type === "JSON") {
// const interfaceName =
// columnKey[0].toUpperCase() + columnKey.slice(1);
// addJsonFieldDefinition(columnKey, interfaceName);
// const def = `static "${columnKey}": ${interfaceName}`;
// return def;
// }
// return `static "${columnKey}": ${getFieldProp("dataType", type)}`;
// }),
// ].join(";\n") + ";";
const availableFields = Object.keys(columns)
.map((columnKey: string) => `"${columnKey}"`)
.join("|\n");
// const availableFields = Object.keys(columns)
// .map((columnKey: string) => `"${columnKey}"`)
// .join("|\n");
monaco.languages.typescript.javascriptDefaults.addExtraLib(
["/**", " * extensions type configuration", " */", extensionsDefs].join(
"\n"
),
"ts:filename/extensions.d.ts"
);
monaco.languages.typescript.javascriptDefaults.addExtraLib(
[
"// basic types that are used in all places",
"declare var require: any;",
"declare var Buffer: any;",
"const ref: FirebaseFirestore.DocumentReference;",
"const storage: firebasestorage.Storage;",
"const db: FirebaseFirestore.Firestore;",
"const auth: firebaseauth.BaseAuth;",
`type Row = {${rowDefinition}};`,
`type Field = ${availableFields} | string | object;`,
`type Fields = Field[];`,
].join("\n"),
"ts:filename/rowFields.d.ts"
);
};
// monaco.languages.typescript.javascriptDefaults.addExtraLib(
// ["/**", " * extensions type configuration", " */", extensionsDefs].join(
// "\n"
// ),
// "ts:filename/extensions.d.ts"
// );
// monaco.languages.typescript.javascriptDefaults.addExtraLib(
// [
// "// basic types that are used in all places",
// "declare var require: any;",
// "declare var Buffer: any;",
// "const ref: FirebaseFirestore.DocumentReference;",
// "const storage: firebasestorage.Storage;",
// "const db: FirebaseFirestore.Firestore;",
// "const auth: firebaseauth.BaseAuth;",
// `type Row = {${rowDefinition}};`,
// `type Field = ${availableFields} | string | object;`,
// `type Fields = Field[];`,
// ].join("\n"),
// "ts:filename/rowFields.d.ts"
// );
// };
// TODO:
// Set row definitions
useEffect(() => {
if (!monaco || !rowyRun || !tableState?.columns) return;
try {
setBaseDefinitions(monaco, tableState.columns);
} catch (error) {
console.error("Could not set basic", error);
}
// set available secrets from secretManager
try {
setSecrets(monaco, rowyRun);
} catch (error) {
console.error("Could not set secrets: ", error);
}
}, [monaco, tableState?.columns, rowyRun]);
// useEffect(() => {
// if (!monaco || !rowyRun || !tableState?.columns) return;
// try {
// setBaseDefinitions(monaco, tableState.columns);
// } catch (error) {
// console.error("Could not set basic", error);
// }
// // set available secrets from secretManager
// try {
// setSecrets(monaco, rowyRun);
// } catch (error) {
// console.error("Could not set secrets: ", error);
// }
// }, [monaco, tableState?.columns, rowyRun]);
let boxSx: SystemStyleObject<Theme> = {
minWidth: 400,

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* utility functions
*/

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { useAtom } from "jotai";
import {
Dialog,
@@ -11,22 +12,35 @@ import {
} from "@mui/material";
import { SlideTransitionMui } from "@src/components/Modal/SlideTransition";
import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope";
/**
* Display a confirm dialog using `confirmDialogAtom` in `globalState`
* {@link confirmDialogAtom | See usage example}
*/
export default function ConfirmDialog() {
const [
{
open,
title = "Are you sure?",
body,
handleConfirm,
confirm = "Confirm",
confirmationCommand,
confirmColor,
handleCancel,
cancel = "Cancel",
hideCancel,
maxWidth = "xs",
},
setState,
] = useAtom(confirmDialogAtom, globalScope);
const handleClose = () => setState({ open: false });
export default function Confirmation({
title,
customBody,
body,
cancel,
hideCancel,
confirm,
confirmationCommand,
handleConfirm,
handleCancel,
confirmColor,
open,
handleClose,
maxWidth = "xs",
}: any) {
const [dryText, setDryText] = useState("");
return (
@@ -40,11 +54,14 @@ export default function Confirmation({
TransitionComponent={SlideTransitionMui}
style={{ cursor: "default" }}
>
<DialogTitle>{title ?? "Are you sure?"}</DialogTitle>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
{customBody}
{body && <DialogContentText>{body}</DialogContentText>}
{typeof body === "string" ? (
<DialogContentText>{body}</DialogContentText>
) : (
body
)}
{confirmationCommand && (
<TextField
value={dryText}
@@ -67,12 +84,12 @@ export default function Confirmation({
handleClose();
}}
>
{cancel ?? "Cancel"}
{cancel}
</Button>
)}
<Button
onClick={() => {
handleConfirm();
if (handleConfirm) handleConfirm();
handleClose();
}}
color={confirmColor || "primary"}
@@ -82,7 +99,7 @@ export default function Confirmation({
confirmationCommand ? dryText !== confirmationCommand : false
}
>
{confirm ?? "Confirm"}
{confirm}
</Button>
</DialogActions>
</Dialog>

View File

@@ -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}
<Dialog
open={showDialog}
onClose={handleClose}
TransitionComponent={SlideTransitionMui}
maxWidth="xs"
>
<DialogTitle>
{(message && message.title) || "Are you sure?"}
</DialogTitle>
{message && (
<DialogContent>
{message.customBody}
{message.body &&
(typeof message.body === "string" ? (
<DialogContentText>{message.body}</DialogContentText>
) : (
message.body
))}
{confirmationCommand && (
<div className={classes.dryWrapper}>
<DialogContentText>
Type {confirmationCommand} below to continue:
</DialogContentText>
<TextField
value={dryText}
variant="filled"
onChange={(e) => {
setDryText(e.target.value);
}}
className={classes.dryField}
InputProps={{ disableUnderline: true }}
autoFocus
margin="dense"
label={confirmationCommand}
fullWidth
/>
</div>
)}
</DialogContent>
)}
<DialogActions>
<Button onClick={handleClose}>
{(message && message.cancel) || "Cancel"}
</Button>
<Button
onClick={() => {
confirmHandler();
handleClose();
}}
variant="contained"
color={message?.color || "primary"}
autoFocus
disabled={
confirmationCommand ? dryText !== confirmationCommand : false
}
>
{(message && message.confirm) || "Confirm"}
</Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -1,8 +0,0 @@
import React, { useContext } from "react";
import { IConfirmation, CONFIRMATION_EMPTY_STATE } from "./props";
const ConfirmationContext = React.createContext<IConfirmation>(
CONFIRMATION_EMPTY_STATE
);
export default ConfirmationContext;
export const useConfirmation = () => useContext(ConfirmationContext);

View File

@@ -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<IConfirmationProviderProps> = ({
children,
}) => {
const [state, setState] = useState<confirmationProps>();
const [open, setOpen] = useState(false);
const handleClose = () => {
setOpen(false);
setTimeout(() => setState(undefined), 300);
};
const requestConfirmation = (props: confirmationProps) => {
setState(props);
setOpen(true);
};
return (
<ConfirmationContext.Provider
value={{
dialogProps: state,
open,
handleClose,
requestConfirmation,
}}
>
{children}
<Dialog {...state} open={open} handleClose={handleClose} />
</ConfirmationContext.Provider>
);
};
export default ConfirmationProvider;

View File

@@ -1 +0,0 @@
export { useConfirmation } from "./Context";

View File

@@ -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: () => {},
};

View File

@@ -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 (
<EmptyState
Icon={ReloadIcon}
message="New update available"
description={
<>
<span>Reload this page to get the latest update</span>
<Button
variant="outlined"
color="secondary"
startIcon={<ReloadIcon />}
onClick={() => window.location.reload()}
>
Reload
</Button>
</>
}
fullScreen
/>
);
return (
<EmptyState
message="Something went wrong"
description={
<>
<span>{this.state.errorMessage}</span>
<Button
href={
meta.repository.url.replace(".git", "") + "/issues/new/choose"
}
target="_blank"
rel="noopener noreferrer"
>
Report issue
<InlineOpenInNewIcon />
</Button>
</>
}
fullScreen
{...this.props}
/>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -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 (
<EmptyState
Icon={ReloadIcon}
message="New update available"
description={
<>
<span>Reload this page to get the latest update</span>
<Button
variant="outlined"
color="secondary"
startIcon={<ReloadIcon />}
onClick={() => window.location.reload()}
>
Reload
</Button>
</>
}
fullScreen
/>
);
if ((error as any).code === "permission-denied") return <AccessDenied />;
return (
<EmptyState
message="Something went wrong"
description={
<>
<span>
{(error as any).code && <b>{(error as any).code}: </b>}
{error.message}
</span>
<Button
href={
meta.repository.url.replace(".git", "") + "/issues/new/choose"
}
target="_blank"
rel="noopener noreferrer"
>
Report issue
<InlineOpenInNewIcon />
</Button>
</>
}
fullScreen
{...props}
/>
);
}

View File

@@ -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<typeof onAuthStateChanged>;
// 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 (
<>
<Typography
variant="button"
display="block"
textAlign="center"
color="textSecondary"
sx={{ mt: -1, mb: -3 }}
>
Continue with
</Typography>
<div className={cx(classes.root, props.className)} id={ELEMENT_ID} />
</>
);
}

View File

@@ -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<FilledTextFieldProps> {
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}

View File

@@ -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 <Chip size="small" color={paletteColor[label]} {...props} />;
}
return <Chip size="small" {...props} />;
}

View File

@@ -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 (
<div
{...props}
style={{
marginTop: theme.spacing(-3),
padding: theme.spacing(0, 1.5),
...(theme.typography.body2 as any),
color: theme.palette.text.secondary,
}}
/>
);
}

View File

@@ -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 (
<EmptyState
fullScreen
Icon={SecurityIcon}
message="Access denied"
description={
<>
<Typography>
You are signed in as <strong>{currentUser?.email}</strong>
</Typography>
<Typography>
You do not have access to this project. Please contact the project
owner.
</Typography>
<Typography>
If you are the project owner, please follow{" "}
<MuiLink
href={WIKI_LINKS.setupRoles}
target="_blank"
rel="noopener noreferrer"
>
these instructions
</MuiLink>{" "}
to set up this projects security rules.
</Typography>
<Button component={Link} to={routes.signOut}>
Sign out
</Button>
</>
}
sx={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: "background.default",
zIndex: 9999,
}}
/>
);
}

View File

@@ -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 (
<Container component="main" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
<Paper
sx={{
height: 48,
maxWidth: (theme) => theme.breakpoints.values.sm - 48,
width: { xs: "100%", md: "50%", lg: "100%" },
mx: "auto",
}}
/>
<Box component="section" sx={{ mt: 4 }}>
<SectionHeadingSkeleton sx={{ pl: 2, pr: 1 }} />
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TableCardSkeleton />
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TableCardSkeleton />
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TableCardSkeleton />
</Grid>
</Grid>
</Box>
<Box component="section" sx={{ mt: 4 }}>
<SectionHeadingSkeleton sx={{ pl: 2, pr: 1 }} />
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TableCardSkeleton />
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TableCardSkeleton />
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TableCardSkeleton />
</Grid>
</Grid>
</Box>
</Container>
);
}

View File

@@ -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 (
<Container component="main" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
<Paper
sx={{
height: 48,
maxWidth: (theme) => theme.breakpoints.values.sm - 48,
width: { xs: "100%", md: "50%", lg: "100%" },
mx: "auto",
}}
/>
<Box component="section" sx={{ mt: 4 }}>
<SectionHeadingSkeleton sx={{ pl: 2, pr: 1 }} />
<Paper>
<TableListItemSkeleton />
<TableListItemSkeleton />
<TableListItemSkeleton />
</Paper>
</Box>
<Box component="section" sx={{ mt: 4 }}>
<SectionHeadingSkeleton sx={{ pl: 2, pr: 1 }} />
<Paper>
<TableListItemSkeleton />
<TableListItemSkeleton />
<TableListItemSkeleton />
</Paper>
</Box>
</Container>
);
}

View File

@@ -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<React.ComponentProps<typeof IconButton>>;
tooltipProps?: Partial<React.ComponentProps<typeof Tooltip>>;
iconProps?: Partial<React.ComponentProps<typeof InfoIcon>>;
}
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 (
<Tooltip
title={
<>
{description}
<IconButton
aria-label={`Close ${buttonLabel}`}
size="small"
onClick={handleClose}
sx={{
m: -0.5,
opacity: 0.8,
"&:hover": {
backgroundColor: (theme) =>
alpha("#fff", theme.palette.action.hoverOpacity),
},
}}
color="inherit"
>
<CloseIcon fontSize="small" />
</IconButton>
</>
}
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
)}
>
<IconButton
aria-label={buttonLabel}
size="small"
{...buttonProps}
onClick={toggleOpen}
>
{buttonProps?.children || <InfoIcon fontSize="small" {...iconProps} />}
</IconButton>
</Tooltip>
);
}

View File

@@ -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<string, string>;
onChange: (value: Record<string, string>) => 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<string, string>)
);
};
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<HTMLInputElement>) =>
setValue((v) => {
const newValue = [...v];
newValue[i][j] = e.target.value;
saveValue(newValue);
return newValue;
});
return (
<FormControl variant="filled" style={{ alignItems: "flex-start" }}>
<FormLabel
component="legend"
sx={{ typography: "button", color: "text.primary", mb: 0.25, ml: 0.25 }}
>
{label}
</FormLabel>
<FormGroup>
{value.map(([propKey, propValue], i) => (
<Stack
key={i}
direction="row"
alignItems="flex-start"
sx={{ "& + &": { mt: 1 } }}
>
<TextField
id={`keyValue-${i}-key`}
aria-label="Key"
placeholder="Key"
value={propKey}
sx={{
"& .MuiInputBase-root": {
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
}}
onChange={handleChange(i, 0)}
error={propKey.length === 0}
helperText={propKey.length === 0 ? "Required" : ""}
/>
<TextField
id={`keyValue-${i}-value`}
aria-label="Value"
placeholder="Value"
value={propValue}
sx={{
ml: "-1px",
"& .MuiInputBase-root": {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
}}
onChange={handleChange(i, 1)}
/>
<Button
onClick={handleRemove(i)}
aria-label="Remove row"
sx={{ ml: 1, px: "0 !important", minWidth: 32 }}
color="error"
>
<RemoveIcon />
</Button>
</Stack>
))}
</FormGroup>
<Button
onClick={handleAdd(value.length - 1)}
color="primary"
startIcon={<AddIcon />}
sx={{ mt: 1 }}
>
Add row
</Button>
</FormControl>
);
}

View File

@@ -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<IScrollableDialogContentProps>;

View File

@@ -1,4 +1,4 @@
import { ReactNode, useState } from "react";
import { useState } from "react";
import {
useTheme,
@@ -26,12 +26,12 @@ export interface IModalProps extends Partial<Omit<DialogProps, "title">> {
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<LoadingButtonProps>;
@@ -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

View File

@@ -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<TransitionProps, React.ReactText> & React.RefAttributes<any>
> = React.forwardRef(
> = forwardRef(
({ children, ...props }: TransitionProps, ref: React.Ref<any>) => {
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<any, any> },
ref: React.Ref<unknown>
) {

View File

@@ -0,0 +1,2 @@
export * from "./Modal";
export { default } from "./Modal";

View File

@@ -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<string[]>(
"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 (
<MuiBreadcrumbs
separator={<ArrowRightIcon />}
aria-label="Sub-table breadcrumbs"
{...props}
sx={[
{
"& .MuiBreadcrumbs-ol": {
userSelect: "none",
flexWrap: "nowrap",
whiteSpace: "nowrap",
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Section name */}
{section && (
<Link
component={RouterLink}
to={`${routes.home}#${_camelCase(section)}`}
variant="h6"
color="textSecondary"
underline="hover"
>
{section}
</Link>
)}
{breadcrumbs.map((crumb: string, index) => {
// If its 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 its the last crumb, just show the label without linking
if (index === breadcrumbs.length - 1)
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Typography {...crumbProps}>
{getLabel(crumb) || crumb.replace(/([A-Z])/g, " $1")}
</Typography>
{crumb === table?.id && table?.readOnly && (
<Tooltip
title={
userClaims?.roles.includes("ADMIN")
? "Table is read-only for non-ADMIN users"
: "Table is read-only"
}
>
<ReadOnlyIcon fontSize="small" sx={{ ml: 0.5 }} />
</Tooltip>
)}
{crumb === table?.id && table?.description && (
<InfoTooltip
description={
<div>
<RenderedMarkdown
children={table?.description}
restrictionPreset="singleLine"
/>
</div>
}
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])}
/>
)}
</div>
);
// 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 (
<Link
{...crumbProps}
component={RouterLink}
to={`${routes.table}/${encodeURIComponent(
breadcrumbs.slice(0, index).join("/")
)}?rowRef=${breadcrumbs.slice(0, index + 1).join("%2F")}`}
underline="hover"
>
{getLabel(
parentLabel.split(",")[Math.ceil(index / 2) - 1] || crumb
)}
</Link>
);
// Otherwise, even: breadcrumb points to a Firestore collection
return (
<Link
{...crumbProps}
component={RouterLink}
to={`${routes.table}/${encodeURIComponent(
breadcrumbs.slice(0, index + 1).join("/")
)}`}
underline="hover"
>
{getLabel(crumb) || crumb.replace(/([A-Z])/g, " $1")}
</Link>
);
})}
</MuiBreadcrumbs>
);
}

View File

@@ -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 (
<li>
<NavItem
{...({ component: "button" } as any)}
selected={!open && currentSection === section}
onClick={() => setOpen((o) => !o)}
>
<ListItemText primary={section} style={{ textAlign: "left" }} />
<ArrowDropDownIcon
sx={{
color: "action.active",
transform: open ? "rotate(180deg)" : "rotate(0)",
transition: (theme) => theme.transitions.create("transform"),
}}
/>
</NavItem>
<Collapse in={open}>
<List disablePadding>
{tables
.filter((x) => x)
.map((table) => {
const route =
table.tableType === "collectionGroup"
? `${routes.tableGroup}/${table.id}`
: `${routes.table}/${table.id.replace(/\//g, "~2F")}`;
return (
<li key={table.id}>
<NavItem
to={route}
selected={pathname.split("%2F")[0] === route}
onClick={closeDrawer}
sx={{
ml: 2,
width: (theme) =>
`calc(100% - ${theme.spacing(2 + 0.5)})`,
}}
>
<ListItemText primary={table.name} />
</NavItem>
</li>
);
})}
</List>
</Collapse>
</li>
);
}

View File

@@ -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<HTMLButtonElement | null>(
null
);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
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 (
<>
<IconButton onClick={handleClick}>
<Badge
color={"primary"}
variant="standard"
badgeContent={notificationsCount}
>
<BellIcon />
</Badge>
</IconButton>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
>
<List>
{notifications.map((notification) => (
<ListItem>
<ListItemAvatar>
<Avatar>
<ErrorIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={notification.title}
secondary={notification.subtitle}
/>
<ListItemSecondaryAction>
<IconButton edge="end" aria-label="Delete">
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
</Popover>
</>
);
};
export default Notification;

View File

@@ -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>,
HTMLDivElement
> {
html: string;
}
export default function RenderedHtml({
html,
className,
...props
}: IRenderedHtmlProps) {
const classes = useStyles();
return (
<div
className={clsx(classes.root, className)}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
{...props}
/>
);
}

View File

@@ -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) => <Link color="inherit" {...props} />,
p: Typography,
// eslint-disable-next-line jsx-a11y/alt-text
img: (props) => (
<img style={{ maxWidth: "100%", borderRadius: 4 }} {...props} />
<img style={{ maxWidth: "100%", borderRadius: 4 }} alt="" {...props} />
),
};
@@ -29,7 +29,9 @@ export default function RenderedMarkdown({
return (
<ReactMarkdown
{...props}
allowedElements={restrictionPresets[restrictionPreset || ""]}
allowedElements={
restrictionPreset ? restrictionPresets[restrictionPreset] : undefined
}
unwrapDisallowed
linkTarget="_blank"
remarkPlugins={remarkPlugins}

View File

@@ -1,179 +0,0 @@
import { useState } from "react";
import clsx from "clsx";
import { Editor } from "@tinymce/tinymce-react";
// TinyMCE so the global var exists
import "tinymce/tinymce.min.js";
// Theme
import "tinymce/themes/silver";
// Toolbar icons
import "tinymce/icons/default";
// Editor styles
import "tinymce/skins/ui/oxide/skin.min.css";
// Content styles, including inline UI like fake cursors
/* eslint import/no-webpack-loader-syntax: off */
import contentCss from "!!raw-loader!tinymce/skins/content/default/content.min.css";
import contentUiCss from "!!raw-loader!tinymce/skins/ui/oxide/content.min.css";
import contentCssDark from "!!raw-loader!tinymce/skins/content/dark/content.min.css";
import contentUiCssDark from "!!raw-loader!tinymce/skins/ui/oxide-dark/content.min.css";
// Plugins
import "tinymce/plugins/autoresize";
import "tinymce/plugins/lists";
import "tinymce/plugins/link";
import "tinymce/plugins/image";
import "tinymce/plugins/paste";
import "tinymce/plugins/help";
import "tinymce/plugins/code";
import { makeStyles, createStyles } from "@mui/styles";
import { useTheme } from "@mui/material";
const useStyles = makeStyles((theme) =>
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 (
<div
className={clsx(
classes.root,
focus && classes.focus,
disabled && classes.disabled
)}
>
<Editor
disabled={disabled}
init={{
skin: false,
content_css: false,
content_style: [
theme.palette.mode === "dark" ? contentCssDark : contentCss,
theme.palette.mode === "dark" ? contentUiCssDark : contentUiCss,
`
:root {
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html, body { background-color: transparent; }
body {
font-family: ${theme.typography.fontFamily};
color: ${theme.palette.text.primary};
caret-color: ${theme.palette.primary.main};
margin: ${theme.spacing(12 / 8)};
margin-top: ${theme.spacing(1)};
padding: 0 !important;
}
body :first-child { margin-top: 0; }
a { color: ${theme.palette.primary.main}; }
`,
].join("\n"),
minHeight: 300,
menubar: false,
plugins: ["autoresize", "lists link image", "paste help", "code"],
statusbar: false,
toolbar:
"formatselect | bold italic forecolor | link | bullist numlist outdent indent | removeformat code | help",
body_id: id,
}}
value={value}
onEditorChange={onChange}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
/>
</div>
);
}

View File

@@ -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<Omit<TooltipProps, "title">> {
render: (props: {
openTooltip: () => void;
closeTooltip: () => void;
toggleTooltip: () => void;
}) => TooltipProps["children"];
icon?: React.ReactNode;
title: React.ReactNode;
message?: React.ReactNode;
dismissButtonText?: React.ReactNode;
dismissButtonProps?: Partial<ButtonProps>;
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 (
<Tooltip
disableFocusListener
disableHoverListener
disableTouchListener
arrow
open={open}
onClose={closeTooltip}
classes={{
popper: classes.popper,
tooltip: classes.tooltip,
arrow: classes.arrow,
}}
title={
<div className={classes.grid} onClick={closeTooltip}>
<span className={classes.icon}>{icon}</span>
<div className={classes.message}>
<Typography variant="subtitle2" gutterBottom>
{title}
</Typography>
<Typography>{message}</Typography>
</div>
{dismissButtonText ? (
<Button
{...dismissButtonProps}
onClick={closeTooltip}
className={clsx(
classes.dismissButton,
dismissButtonProps?.className
)}
>
{dismissButtonText}
</Button>
) : (
<Typography
variant="caption"
color="text.disabled"
className={classes.dismissButton}
>
Click to dismiss
</Typography>
)}
</div>
}
PopperProps={{
modifiers: [
{
name: "preventOverflow",
enabled: true,
options: {
altAxis: true,
altBoundary: true,
tether: false,
rootBoundary: "document",
padding: 8,
},
},
],
}}
{...props}
>
{render({ openTooltip, closeTooltip, toggleTooltip })}
</Tooltip>
);
}

View File

@@ -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 (
<Modal
open={state.open}
open={rowyRunModal.open}
onClose={handleClose}
title={
<Logo
@@ -47,13 +58,13 @@ export default function RowyRunModal() {
<>
<Typography variant="h5" paragraph align="center">
{showUpdateModal ? "Update" : "Set up"} Rowy Run to use{" "}
{state.feature || "this feature"}
{rowyRunModal.feature || "this feature"}
</Typography>
{showUpdateModal && (
<DialogContentText variant="body1" paragraph textAlign="center">
{state.feature || "This feature"} requires Rowy Run v
{state.version} or later.
{rowyRunModal.feature || "This feature"} requires Rowy Run v
{rowyRunModal.version} or later.
</DialogContentText>
)}
@@ -73,7 +84,7 @@ export default function RowyRunModal() {
<Button
component={Link}
to={routes.projectSettings + "#rowyRun"}
to={ROUTES.projectSettings + "#rowyRun"}
variant="contained"
color="primary"
size="large"

View File

@@ -1,11 +1,11 @@
import { forwardRef } from "react";
import _camelCase from "lodash/camelCase";
import { camelCase } from "lodash-es";
import { HashLink } from "react-router-hash-link";
import { Stack, StackProps, Typography, IconButton } from "@mui/material";
import LinkIcon from "@mui/icons-material/Link";
import { APP_BAR_HEIGHT } from "@src/components/Navigation";
import { APP_BAR_HEIGHT } from "@src/layouts/Navigation";
export interface ISectionHeadingProps extends Omit<StackProps, "children"> {
children: string;
@@ -15,7 +15,7 @@ export const SectionHeading = forwardRef(function SectionHeading_(
{ children, sx, ...props }: ISectionHeadingProps,
ref
) {
const sectionLink = _camelCase(children);
const sectionLink = camelCase(children);
return (
<Stack

View File

@@ -1,3 +1,5 @@
import { useAtom } from "jotai";
import { Grid, Typography, Button, Link, Divider } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import GitHubIcon from "@mui/icons-material/GitHub";
@@ -7,13 +9,13 @@ import TwitterIcon from "@mui/icons-material/Twitter";
import Logo from "@src/assets/Logo";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { version } from "@root/package.json";
import { useAppContext } from "@src/contexts/AppContext";
import meta from "@root/package.json";
import { globalScope, projectIdAtom } from "@src/atoms/globalScope";
import useUpdateCheck from "@src/hooks/useUpdateCheck";
import { EXTERNAL_LINKS, WIKI_LINKS } from "@src/constants/externalLinks";
export default function About() {
const { projectId } = useAppContext();
const [projectId] = useAtom(projectIdAtom, globalScope);
const [latestUpdate, checkForUpdates, loading] = useUpdateCheck();
@@ -100,7 +102,7 @@ export default function About() {
)}
<Typography display="block" color="textSecondary">
Rowy v{version}
Rowy v{meta.version}
</Typography>
</Grid>

View File

@@ -1,12 +1,12 @@
import { useState } from "react";
import { authOptions } from "firebase/firebaseui";
import _startCase from "lodash/startCase";
import { startCase } from "lodash-es";
import MultiSelect from "@rowy/multiselect";
import { Typography, Link } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { IProjectSettingsChildProps } from "@src/pages/Settings/ProjectSettings";
import { authOptions } from "@src/config/firebaseui";
export default function Authentication({
publicSettings,
@@ -25,7 +25,7 @@ export default function Authentication({
value={signInOptions}
options={Object.keys(authOptions).map((option) => ({
value: option,
label: _startCase(option).replace("Github", "GitHub"),
label: startCase(option).replace("Github", "GitHub"),
}))}
onChange={setSignInOptions}
onClose={() => updatePublicSettings({ signInOptions })}

View File

@@ -1,13 +1,12 @@
import { lazy, Suspense, useState } from "react";
import { IProjectSettingsChildProps } from "@src/pages/Settings/ProjectSettings";
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 Customization({
publicSettings,
@@ -20,7 +19,7 @@ export default function Customization({
const handleSave = ({ light, dark }: { light: string; dark: string }) => {
updatePublicSettings({
theme: _merge(publicSettings.theme, {
theme: merge(publicSettings.theme, {
light: { palette: { primary: { main: light } } },
dark: { palette: { primary: { main: dark } } },
}),
@@ -37,8 +36,8 @@ export default function Customization({
setCustomizedThemeColor(e.target.checked);
if (!e.target.checked) {
const newTheme = publicSettings.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");
updatePublicSettings({ theme: newTheme });
}
}}

View File

@@ -14,7 +14,7 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import LogoRowyRun from "@src/assets/LogoRowyRun";
import { IProjectSettingsChildProps } from "@src/pages/Settings/ProjectSettings";
import { EXTERNAL_LINKS, WIKI_LINKS } from "@src/constants/externalLinks";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import useUpdateCheck from "@src/hooks/useUpdateCheck";
import { runRoutes } from "@src/constants/runRoutes";

View File

@@ -57,7 +57,7 @@ export default function ThemeColorPicker({
width={244}
height={140}
color={toColor("hex", light)}
onChange={(c: any) => setLight(c.hex)}
onChange={(c) => setLight(c.hex)}
dark={theme.palette.mode === "dark"}
/>

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { useAtom, useSetAtom } from "jotai";
import { Link } from "react-router-dom";
import { useSnackbar } from "notistack";
@@ -14,15 +15,22 @@ import AddIcon from "@mui/icons-material/PersonAddOutlined";
import MultiSelect from "@rowy/multiselect";
import Modal from "@src/components/Modal";
import { useProjectContext } from "@src/contexts/ProjectContext";
import routes from "@src/constants/routes";
import {
globalScope,
rolesAtom,
projectSettingsAtom,
rowyRunAtom,
rowyRunModalAtom,
} from "@src/atoms/globalScope";
import { ROUTES } from "@src/constants/routes";
import { runRoutes } from "@src/constants/runRoutes";
import { useRowyRunModal } from "@src/atoms/RowyRunModal";
export default function InviteUser() {
const { roles: projectRoles, rowyRun, settings } = useProjectContext();
const [projectRoles] = useAtom(rolesAtom, globalScope);
const [projectSettings] = useAtom(projectSettingsAtom, globalScope);
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
const openRowyRunModal = useSetAtom(rowyRunModalAtom, globalScope);
const { enqueueSnackbar } = useSnackbar();
const openRowyRunModal = useRowyRunModal();
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<"LOADING" | string>("");
@@ -33,7 +41,7 @@ export default function InviteUser() {
const handleInvite = async () => {
try {
setStatus("LOADING");
const res = await rowyRun?.({
const res = await rowyRun({
route: runRoutes.inviteUser,
body: { email, roles },
});
@@ -52,9 +60,9 @@ export default function InviteUser() {
<Button
aria-label="Invite user"
onClick={
settings?.rowyRunUrl
projectSettings.rowyRunUrl
? () => setOpen(true)
: () => openRowyRunModal("Invite user")
: () => openRowyRunModal({ feature: "Invite user" })
}
variant="text"
color="primary"
@@ -78,7 +86,7 @@ export default function InviteUser() {
They can sign up with any of the sign-in options{" "}
<MuiLink
component={Link}
to={routes.projectSettings + "#authentication"}
to={ROUTES.projectSettings + "#authentication"}
>
you have enabled
</MuiLink>
@@ -104,7 +112,7 @@ export default function InviteUser() {
TextFieldProps={{
id: "invite-roles",
SelectProps: {
renderValue: (_) => {
renderValue: () => {
if (Array.isArray(roles)) {
if (roles.length >= 1) return roles.join(", ");
return (
@@ -113,6 +121,7 @@ export default function InviteUser() {
</Typography>
);
}
return null;
},
},
sx: { mt: 3 },

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useSnackbar } from "notistack";
import {
@@ -14,18 +15,33 @@ import CopyIcon from "@src/assets/icons/Copy";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import MultiSelect from "@rowy/multiselect";
import { User } from "@src/pages/Settings/UserManagement";
import { useProjectContext } from "@src/contexts/ProjectContext";
import {
globalScope,
rolesAtom,
projectSettingsAtom,
rowyRunAtom,
rowyRunModalAtom,
UserSettings,
updateUserAtom,
confirmDialogAtom,
} from "@src/atoms/globalScope";
import { runRoutes } from "@src/constants/runRoutes";
import { db } from "@src/firebase";
import { USERS } from "@src/config/dbPaths";
import { useConfirmation } from "@src/components/ConfirmationDialog";
export default function UserItem({ id, user, roles: rolesProp }: User) {
export default function UserItem({
_rowy_id,
user,
roles: rolesProp,
}: UserSettings) {
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const { requestConfirmation } = useConfirmation();
const confirm = useSetAtom(confirmDialogAtom, globalScope);
const openRowyRunModal = useSetAtom(rowyRunModalAtom, globalScope);
const { roles: projectRoles, rowyRun } = useProjectContext();
const [projectRoles] = useAtom(rolesAtom, globalScope);
const [projectSettings] = useAtom(projectSettingsAtom, globalScope);
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
const [updateUser] = useAtom(updateUserAtom, globalScope);
const [value, setValue] = useState(Array.isArray(rolesProp) ? rolesProp : []);
const allRoles = new Set(["ADMIN", ...(projectRoles ?? []), ...value]);
@@ -42,7 +58,8 @@ export default function UserItem({ id, user, roles: rolesProp }: User) {
body: { email: user!.email, roles: value },
});
if (res.success) {
await db.collection(USERS).doc(id).update({ roles: value });
if (!updateUser) throw new Error("Could not update user document");
await updateUser(_rowy_id!, { roles: value });
closeSnackbar(loadingSnackbarId);
enqueueSnackbar(`Set roles for ${user!.email}: ${value.join(", ")}`);
}
@@ -72,9 +89,15 @@ export default function UserItem({ id, user, roles: rolesProp }: User) {
);
const handleDelete = async () => {
requestConfirmation({
if (!projectSettings.rowyRunUrl) {
openRowyRunModal({ feature: "User Management" });
return;
}
confirm({
open: true,
title: "Delete user?",
customBody: (
body: (
<>
<ListItem children={listItemChildren} disablePadding sx={{ mb: 3 }} />
You will delete the user in Firebase Authentication and the
@@ -86,12 +109,12 @@ export default function UserItem({ id, user, roles: rolesProp }: User) {
handleConfirm: async () => {
if (!user) return;
const loadingSnackbarId = enqueueSnackbar("Deleting user…");
await rowyRun?.({
const response = await rowyRun({
route: runRoutes.deleteUser,
body: { email: user.email },
});
closeSnackbar(loadingSnackbarId);
enqueueSnackbar(`Deleted user: ${user.email}`);
if (response) enqueueSnackbar(`Deleted user: ${user.email}`);
},
});
};
@@ -109,7 +132,7 @@ export default function UserItem({ id, user, roles: rolesProp }: User) {
freeText
TextFieldProps={{
SelectProps: {
renderValue: (_) => {
renderValue: () => {
if (Array.isArray(value)) {
if (value.length === 1) return value[0];
if (value.length > 1) return `${value.length} roles`;
@@ -119,6 +142,7 @@ export default function UserItem({ id, user, roles: rolesProp }: User) {
</Typography>
);
}
return null;
},
},
@@ -155,9 +179,9 @@ export default function UserItem({ id, user, roles: rolesProp }: User) {
<IconButton
aria-label="Copy UID"
onClick={async () => {
if (!id) return;
await navigator.clipboard.writeText(id);
enqueueSnackbar(`Copied UID for ${user?.email}: ${id}`);
if (!_rowy_id) return;
await navigator.clipboard.writeText(_rowy_id);
enqueueSnackbar(`Copied UID for ${user?.email}: ${_rowy_id}`);
}}
>
<CopyIcon />

View File

@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
import { Grid, Avatar, Typography, Button } from "@mui/material";
import routes from "@src/constants/routes";
import { ROUTES } from "@src/constants/routes";
export default function Account({ settings }: IUserSettingsChildProps) {
return (
@@ -26,7 +26,7 @@ export default function Account({ settings }: IUserSettingsChildProps) {
</Grid>
<Grid item>
<Button component={Link} to={routes.signOut}>
<Button component={Link} to={ROUTES.signOut}>
Sign out
</Button>
</Grid>

View File

@@ -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({
<FormControlLabel
control={
<Checkbox
checked={customizedThemeColor}
defaultChecked={customizedThemeColor}
onChange={(e) => {
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({
/>
<Collapse in={customizedThemeColor} style={{ marginTop: 0 }}>
<Suspense fallback={<Loading />}>
<Suspense fallback={<Loading style={{ height: "auto" }} />}>
<ThemeColorPicker
currentLight={settings.theme?.light?.palette?.primary?.main}
currentDark={settings.theme?.dark?.palette?.primary?.main}

View File

@@ -1,5 +1,6 @@
import { useAtom } from "jotai";
import { merge } from "lodash-es";
import { IUserSettingsChildProps } from "@src/pages/Settings/UserSettings";
import _merge from "lodash/merge";
import {
FormControl,
@@ -10,18 +11,29 @@ import {
Checkbox,
} from "@mui/material";
import { useAppContext } from "@src/contexts/AppContext";
import {
globalScope,
themeAtom,
themeOverriddenAtom,
} from "@src/atoms/globalScope";
export default function Theme({
settings,
updateSettings,
}: IUserSettingsChildProps) {
const { theme, themeOverridden, setTheme, setThemeOverridden } =
useAppContext();
const [theme, setTheme] = useAtom(themeAtom, globalScope);
const [themeOverridden, setThemeOverridden] = useAtom(
themeOverriddenAtom,
globalScope
);
return (
<>
<FormControl component="fieldset" variant="standard" sx={{ my: -10 / 8 }}>
<FormControl
component="fieldset"
variant="standard"
sx={{ my: -10 / 8, display: "flex" }}
>
<legend style={{ fontSize: 0 }}>Theme</legend>
<RadioGroup
@@ -50,10 +62,10 @@ export default function Theme({
<FormControlLabel
control={
<Checkbox
checked={settings.theme?.dark?.palette?.darker}
defaultChecked={Boolean(settings.theme?.dark?.palette?.darker)}
onChange={(e) => {
updateSettings({
theme: _merge(settings.theme, {
theme: merge(settings.theme, {
dark: { palette: { darker: e.target.checked } },
}),
});

View File

@@ -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);
};

View File

@@ -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<LoadingButtonProps> {
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);
}

View File

@@ -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
</Button>

View File

@@ -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);

View File

@@ -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 (

View File

@@ -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 (
<>

View File

@@ -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 doesnt 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;
}

View File

@@ -1,14 +0,0 @@
import { Skeleton, SkeletonProps } from "@mui/material";
export default function FieldSkeleton(props: SkeletonProps) {
return (
<Skeleton
variant="rectangular"
width="100%"
height={32}
animation="wave"
sx={{ borderRadius: 1 }}
{...props}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More