mirror of
https://github.com/rowyio/rowy.git
synced 2026-02-24 04:01:17 +01:00
@@ -1,2 +1,2 @@
|
||||
REACT_APP_FIREBASE_PROJECT_ID=
|
||||
REACT_APP_FIREBASE_PROJECT_WEB_API_KEY=
|
||||
VITE_APP_FIREBASE_PROJECT_ID=
|
||||
VITE_APP_FIREBASE_PROJECT_WEB_API_KEY=
|
||||
4
.github/workflows/deploy-preview.yml
vendored
4
.github/workflows/deploy-preview.yml
vendored
@@ -6,8 +6,8 @@ on:
|
||||
# paths:
|
||||
# - "website/**"
|
||||
env:
|
||||
REACT_APP_FIREBASE_PROJECT_ID: rowyio
|
||||
REACT_APP_FIREBASE_PROJECT_WEB_API_KEY:
|
||||
VITE_APP_FIREBASE_PROJECT_ID: rowyio
|
||||
VITE_APP_FIREBASE_PROJECT_WEB_API_KEY:
|
||||
"${{ secrets.FIREBASE_WEB_API_KEY_TRYROWY }}"
|
||||
CI: ""
|
||||
jobs:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
cloud_functions/functions/lib
|
||||
|
||||
# firebase
|
||||
|
||||
@@ -17,16 +17,26 @@ Read the documentation on setting up your local development environment
|
||||
Read how to submit a pull request [here](https://docs.rowy.io/contributing).
|
||||
|
||||
To get familiar with the project,
|
||||
[good first issues](https://github.com/rowyio/rowy/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) is a good place
|
||||
to start.
|
||||
[good first issues](https://github.com/rowyio/rowy/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
|
||||
is a good place to start.
|
||||
|
||||
## Working on existing issues
|
||||
|
||||
Before you get started working on an
|
||||
[issue](https://github.com/rowyio/rowy/issues), please make sure to share that
|
||||
you are working on it by commenting on the issue and posting a message on
|
||||
#contributions channel in Rowy's
|
||||
[Discord](https://discord.com/invite/fjBugmvzZP). The maintainers will then
|
||||
assign the issue to you after making sure any relevant information or context in
|
||||
addition is provided before you can start on the task.
|
||||
|
||||
|
||||
Before you get started working on an [issue](https://github.com/rowyio/rowy/issues), please make sure to share that you are working on it by commenting on the issue and posting a message on #contributions channel in Rowy's [Discord](https://rowy.io/discord). The maintainers will then assign the issue to you after making sure any relevant information or context in addition is provided before you can start on the task.
|
||||
|
||||
Once you are assigned a task, please provide periodic updates or share any questions or roadblocks on either discord or the Github issue, so that the commmunity or the project maintainers can provide you any feedback or guidance as needed. If you are inactive for more than 1-2 week on a issue that was assigned to you, then we will assume you have stopped working on it and we will unassign it from you - so that we can give a chance to others in the community to work on it.
|
||||
Once you are assigned a task, please provide periodic updates or share any
|
||||
questions or roadblocks on either discord or the Github issue, so that the
|
||||
commmunity or the project maintainers can provide you any feedback or guidance
|
||||
as needed. If you are inactive for more than 1-2 week on a issue that was
|
||||
assigned to you, then we will assume you have stopped working on it and we will
|
||||
unassign it from you - so that we can give a chance to others in the community
|
||||
to work on it.
|
||||
|
||||
## File a feature request
|
||||
|
||||
@@ -35,10 +45,9 @@ create a new issue using
|
||||
[Feature Request Template](https://github.com/rowyio/rowy/issues/new?assignees=&labels=&template=feature_request.md)
|
||||
to share your idea. If you are working on this to contribute to the project,
|
||||
then let others in the community and project maintainers know by posting on
|
||||
#contributions channel in Rowy's
|
||||
[Discord](https://rowy.io/discord). This allows others in the
|
||||
community and the maintainers a chance to provide feedback and guidance before
|
||||
you spend time working on it.
|
||||
#contributions channel in Rowy's [Discord](https://rowy.io/discord). This allows
|
||||
others in the community and the maintainers a chance to provide feedback and
|
||||
guidance before you spend time working on it.
|
||||
|
||||
## Report an issue
|
||||
|
||||
|
||||
18
README.md
18
README.md
@@ -55,6 +55,7 @@ Low-code for Firebase and Google Cloud.
|
||||
</td>
|
||||
</tr>
|
||||
</table> -->
|
||||
|
||||
https://user-images.githubusercontent.com/307298/157185793-f67511cd-7b7b-4229-9589-d7defbf7a63f.mp4
|
||||
|
||||
<!-- <img width="85%" src="https://firebasestorage.googleapis.com/v0/b/rowyio.appspot.com/o/publicDemo%2FRowy%20Website%20Video%20GIF%20Small.gif?alt=media&token=3f699a8f-c1f2-4046-8ed5-e4ff66947cd8" />
|
||||
@@ -98,8 +99,10 @@ https://user-images.githubusercontent.com/307298/157185793-f67511cd-7b7b-4229-95
|
||||
|
||||
## Quick guided install
|
||||
|
||||
Set up Rowy on your Google Cloud Platform project with this easy deploy button. Your
|
||||
data and cloud functions stay on your own Firestore/GCP and is managed via a cloud run instance that operates exclusively on your GCP project. So we do do not access or store any of your data on Rowy.
|
||||
Set up Rowy on your Google Cloud Platform project with this easy deploy button.
|
||||
Your data and cloud functions stay on your own Firestore/GCP and is managed via
|
||||
a cloud run instance that operates exclusively on your GCP project. So we do do
|
||||
not access or store any of your data on Rowy.
|
||||
|
||||
[<img width="200" alt="Guided quick start button" src="https://user-images.githubusercontent.com/307298/185548050-e9208fb6-fe53-4c84-bbfa-53c08e03c15f.png">](https://rowy.app/)
|
||||
|
||||
@@ -112,12 +115,17 @@ You can find the full documentation with how-to guides and templates
|
||||
|
||||
## Manual Install
|
||||
|
||||
We recommend the [quick guided install](https://github.com/rowyio/rowy#quick-guided-install) option above. Manual install option is only recommended if you want to develop and contribute to the project. Follow this [guide](https://docs.rowy.io/setup/install#option-2-manual-install) for manual setup.
|
||||
We recommend the
|
||||
[quick guided install](https://github.com/rowyio/rowy#quick-guided-install)
|
||||
option above. Manual install option is only recommended if you want to develop
|
||||
and contribute to the project. Follow this
|
||||
[guide](https://docs.rowy.io/setup/install#option-2-manual-install) for manual
|
||||
setup.
|
||||
|
||||
## Roadmap
|
||||
|
||||
[View our roadmap](https://roadmap.rowy.io/) on Rowy - Upvote,
|
||||
downvote, share your thoughts!
|
||||
[View our roadmap](https://roadmap.rowy.io/) on Rowy - Upvote, downvote, share
|
||||
your thoughts!
|
||||
|
||||
If you'd like to propose a feature, submit an issue
|
||||
[here](https://github.com/rowyio/rowy/issues/new?assignees=&labels=&template=feature_request.md&title=).
|
||||
|
||||
@@ -8,10 +8,10 @@ const main = (
|
||||
) => {
|
||||
return fs.writeFileSync(
|
||||
".env",
|
||||
`REACT_APP_FIREBASE_PROJECT_ID = ${projectID}
|
||||
REACT_APP_FIREBASE_PROJECT_WEB_API_KEY = ${firebaseWebApiKey}
|
||||
REACT_APP_ALGOLIA_APP_ID = ${algoliaAppId}
|
||||
REACT_APP_ALGOLIA_SEARCH_API_KEY = ${algoliaSearhApiKey}`
|
||||
`VITE_APP_FIREBASE_PROJECT_ID = ${projectID}
|
||||
VITE_APP_FIREBASE_PROJECT_WEB_API_KEY = ${firebaseWebApiKey}
|
||||
VITE_APP_ALGOLIA_APP_ID = ${algoliaAppId}
|
||||
VITE_APP_ALGOLIA_SEARCH_API_KEY = ${algoliaSearhApiKey}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"hosting": {
|
||||
"public": "build",
|
||||
"public": "dist",
|
||||
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
||||
"rewrites": [
|
||||
{
|
||||
|
||||
@@ -15,30 +15,30 @@
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="%PUBLIC_URL%/favicon/apple-touch-icon.png"
|
||||
href="/favicon/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="%PUBLIC_URL%/favicon/favicon-32x32.png"
|
||||
href="/favicon/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="%PUBLIC_URL%/favicon/favicon-16x16.png"
|
||||
href="/favicon/favicon-16x16.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="%PUBLIC_URL%/favicon/icon.svg"
|
||||
href="/favicon/icon.svg"
|
||||
id="favicon-svg"
|
||||
/>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link
|
||||
rel="mask-icon"
|
||||
href="%PUBLIC_URL%/favicon/safari-pinned-tab.svg"
|
||||
href="/favicon/safari-pinned-tab.svg"
|
||||
color="#4200FF"
|
||||
/>
|
||||
<meta name="msapplication-TileColor" content="#4200FF" />
|
||||
@@ -47,13 +47,13 @@
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
Unlike "/favicon.ico" or "favicon.ico", "/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
@@ -87,7 +87,7 @@
|
||||
property="og:description"
|
||||
content="Build on the Google Cloud Platform in minutes. Manage Firestore data in a spreadsheet-like UI, write Cloud Functions effortlessly in the browser, and connect to third-party apps. Rowy is open source!"
|
||||
/>
|
||||
<meta property="og:image" content="%PUBLIC_URL%/static/meta.png" />
|
||||
<meta property="og:image" content="/static/meta.png" />
|
||||
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://rowy.io/" />
|
||||
@@ -96,11 +96,12 @@
|
||||
property="twitter:description"
|
||||
content="Build on the Google Cloud Platform in minutes. Manage Firestore data in a spreadsheet-like UI, write Cloud Functions effortlessly in the browser, and connect to third-party apps. Rowy is open source!"
|
||||
/>
|
||||
<meta property="twitter:image" content="%PUBLIC_URL%/static/meta.png" />
|
||||
<meta property="twitter:image" content="/static/meta.png" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
32
package.json
32
package.json
@@ -10,13 +10,15 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
|
||||
"@json2csv/plainjs": "^7.0.1",
|
||||
"@mdi/js": "^6.6.96",
|
||||
"@monaco-editor/react": "^4.4.4",
|
||||
"@mui/icons-material": "^5.10.16",
|
||||
"@mui/lab": "^5.0.0-alpha.76",
|
||||
"@mui/material": "^5.10.16",
|
||||
"@mui/x-date-pickers": "^5.0.0-alpha.4",
|
||||
"@rowy/form-builder": "^0.8.0",
|
||||
"@rowy/form-builder": "^1.0.0",
|
||||
"@rowy/multiselect": "^0.4.1",
|
||||
"@tanstack/react-table": "^8.5.15",
|
||||
"@tinymce/tinymce-react": "^3",
|
||||
@@ -24,6 +26,7 @@
|
||||
"algoliasearch": "^4.13.1",
|
||||
"ansi-to-react": "^6.1.6",
|
||||
"buffer": "^6.0.3",
|
||||
"colord": "^2.9.3",
|
||||
"compare-versions": "^4.1.3",
|
||||
"csv-parse": "^5.1.0",
|
||||
"date-fns": "^2.28.0",
|
||||
@@ -33,13 +36,12 @@
|
||||
"firebaseui": "^6.0.1",
|
||||
"jotai": "^1.8.4",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"json2csv": "^5.0.7",
|
||||
"jszip": "^3.10.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"match-sorter": "^6.3.1",
|
||||
"material-ui-popup-state": "^4.0.1",
|
||||
"mdi-material-ui": "^7.3.0",
|
||||
"monaco-editor-auto-typings": "^0.4.0",
|
||||
"monaco-editor-auto-typings": "^0.4.3",
|
||||
"notistack": "^2.0.4",
|
||||
"path-browserify": "^1.0.1",
|
||||
"pb-util": "^1.0.3",
|
||||
@@ -47,7 +49,6 @@
|
||||
"react": "^18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-color-palette": "^6.2.0",
|
||||
"react-detect-offline": "^2.4.5",
|
||||
"react-div-100vh": "^0.7.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
@@ -62,29 +63,30 @@
|
||||
"react-markdown": "^8.0.3",
|
||||
"react-router-dom": "6.3.0",
|
||||
"react-router-hash-link": "^2.4.3",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-usestateref": "^1.0.8",
|
||||
"react-virtual": "^2.10.4",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"sucrase": "^3.32.0",
|
||||
"swr": "^1.3.0",
|
||||
"tinymce": "^5",
|
||||
"tss-react": "^4.4.4",
|
||||
"typescript": "^4.9.3",
|
||||
"use-algolia": "^1.5.3",
|
||||
"use-async-memo": "^1.2.4",
|
||||
"use-debounce": "^8.0.0",
|
||||
"use-debounce": "^9.0.4",
|
||||
"use-memo-value": "^1.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"workbox-webpack-plugin": "^6.5.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env PORT=7699 craco start",
|
||||
"startWithEmulators": "cross-env PORT=7699 REACT_APP_FIREBASE_EMULATORS=true craco start",
|
||||
"start": "vite --port 7699",
|
||||
"startWithEmulators": "VITE_APP_FIREBASE_EMULATORS=true vite --port 7699",
|
||||
"emulators": "firebase emulators:start --only firestore,auth --import ./emulators/ --export-on-exit",
|
||||
"test": "craco test --env ./src/test/custom-jest-env.js --verbose --detectOpenHandles",
|
||||
"build": "craco build",
|
||||
"test": "vitest",
|
||||
"build": "tsc && cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build",
|
||||
"preview": "vite preview --port 7699",
|
||||
"analyze": "source-map-explorer ./build/static/js/*.js",
|
||||
"prepare": "husky install",
|
||||
"env": "node createDotEnv",
|
||||
@@ -154,7 +156,6 @@
|
||||
"@types/dompurify": "^2.3.3",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/node": "^17.0.23",
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -167,6 +168,8 @@
|
||||
"@types/seedrandom": "^3.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"craco-alias": "^3.0.1",
|
||||
"craco-swc": "^0.5.1",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -176,6 +179,7 @@
|
||||
"eslint-plugin-local-rules": "^1.1.0",
|
||||
"eslint-plugin-no-relative-import-paths": "^1.2.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.16",
|
||||
"happy-dom": "^9.20.3",
|
||||
"husky": ">=7.0.4",
|
||||
"lint-staged": ">=12.3.7",
|
||||
"monaco-editor": "^0.33.0",
|
||||
@@ -183,7 +187,11 @@
|
||||
"raw-loader": "^4.0.2",
|
||||
"source-map-explorer": "^2.5.2",
|
||||
"ts-jest": "^28.0.2",
|
||||
"typedoc": "^0.23.21"
|
||||
"typedoc": "^0.23.21",
|
||||
"vite": "^4.3.9",
|
||||
"vite-plugin-svgr": "^3.2.0",
|
||||
"vite-tsconfig-paths": "^4.2.0",
|
||||
"vitest": "^0.31.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "^18"
|
||||
|
||||
38
src/App.tsx
38
src/App.tsx
@@ -23,6 +23,7 @@ import useKeyPressWithAtom from "@src/hooks/useKeyPressWithAtom";
|
||||
|
||||
import TableGroupRedirectPage from "./pages/TableGroupRedirectPage";
|
||||
import SignOutPage from "@src/pages/Auth/SignOutPage";
|
||||
import ProvidedArraySubTablePage from "./pages/Table/ProvidedArraySubTablePage";
|
||||
|
||||
// prettier-ignore
|
||||
const AuthPage = lazy(() => import("@src/pages/Auth/AuthPage" /* webpackChunkName: "AuthPage" */));
|
||||
@@ -40,6 +41,12 @@ const SetupPage = lazy(() => import("@src/pages/SetupPage" /* webpackChunkName:
|
||||
const Navigation = lazy(() => import("@src/layouts/Navigation" /* webpackChunkName: "Navigation" */));
|
||||
// prettier-ignore
|
||||
const TableSettingsDialog = lazy(() => import("@src/components/TableSettingsDialog" /* webpackChunkName: "TableSettingsDialog" */));
|
||||
const ProjectSettingsDialog = lazy(
|
||||
() =>
|
||||
import(
|
||||
"@src/components/ProjectSettingsDialog" /* webpackChunkName: "ProjectSettingsDialog" */
|
||||
)
|
||||
);
|
||||
|
||||
// prettier-ignore
|
||||
const TablesPage = lazy(() => import("@src/pages/TablesPage" /* webpackChunkName: "TablesPage" */));
|
||||
@@ -50,8 +57,6 @@ const ProvidedSubTablePage = lazy(() => import("@src/pages/Table/ProvidedSubTabl
|
||||
// prettier-ignore
|
||||
const TableTutorialPage = lazy(() => import("@src/pages/Table/TableTutorialPage" /* webpackChunkName: "TableTutorialPage" */));
|
||||
|
||||
// prettier-ignore
|
||||
const FunctionPage = lazy(() => import("@src/pages/FunctionPage" /* webpackChunkName: "FunctionPage" */));
|
||||
// prettier-ignore
|
||||
const UserSettingsPage = lazy(() => import("@src/pages/Settings/UserSettingsPage" /* webpackChunkName: "UserSettingsPage" */));
|
||||
// prettier-ignore
|
||||
@@ -100,6 +105,7 @@ export default function App() {
|
||||
<RequireAuth>
|
||||
<Navigation>
|
||||
<TableSettingsDialog />
|
||||
<ProjectSettingsDialog />
|
||||
</Navigation>
|
||||
</RequireAuth>
|
||||
}
|
||||
@@ -134,6 +140,27 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={ROUTES.arraySubTable}>
|
||||
<Route index element={<NotFound />} />
|
||||
<Route
|
||||
path=":docPath/:subTableKey"
|
||||
element={
|
||||
<Suspense
|
||||
fallback={
|
||||
<Backdrop
|
||||
key="sub-table-modal-backdrop"
|
||||
open
|
||||
sx={{ zIndex: "modal" }}
|
||||
>
|
||||
<Loading />
|
||||
</Backdrop>
|
||||
}
|
||||
>
|
||||
<ProvidedArraySubTablePage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -147,13 +174,6 @@ export default function App() {
|
||||
element={<TableTutorialPage />}
|
||||
/>
|
||||
|
||||
<Route path={ROUTES.function}>
|
||||
<Route
|
||||
index
|
||||
element={<Navigate to={ROUTES.functions} replace />}
|
||||
/>
|
||||
<Route path=":id" element={<FunctionPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path={ROUTES.settings}
|
||||
element={<Navigate to={ROUTES.userSettings} replace />}
|
||||
|
||||
9
src/assets/icons/ArraySubTable.tsx
Normal file
9
src/assets/icons/ArraySubTable.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
|
||||
|
||||
export function ArraySubTable(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="M1 4C1 2.34315 2.34315 1 4 1H18C19.6569 1 21 2.34315 21 4V11H19H12V15V17H4C2.34315 17 1 15.6569 1 14V4ZM10 15V11H3V14C3 14.5523 3.44772 15 4 15H10ZM12 9H19V5H12V9ZM10 5H3V9H10V5ZM15 13H14V14V22V23H15H17V21H16V15H17V13H15ZM21 13H22V14V22V23H21H19V21H20V15H19V13H21Z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
@@ -133,3 +133,16 @@ export const FunctionsIndexAtom = atom<FunctionSettings[]>([]);
|
||||
export const updateFunctionAtom = atom<
|
||||
UpdateCollectionDocFunction<FunctionSettings> | undefined
|
||||
>(undefined);
|
||||
|
||||
export interface ISecretNames {
|
||||
loading: boolean;
|
||||
secretNames: null | string[];
|
||||
}
|
||||
|
||||
export const secretNamesAtom = atom<ISecretNames>({
|
||||
loading: true,
|
||||
secretNames: null,
|
||||
});
|
||||
export const updateSecretNamesAtom = atom<
|
||||
((clearSecretNames?: boolean) => Promise<void>) | undefined
|
||||
>(undefined);
|
||||
|
||||
@@ -131,6 +131,26 @@ export const tableSettingsDialogAtom = atom(
|
||||
}
|
||||
);
|
||||
|
||||
export type ProjectSettingsDialogTab =
|
||||
| "general"
|
||||
| "rowy-run"
|
||||
| "services"
|
||||
| "secrets";
|
||||
export type ProjectSettingsDialogState = {
|
||||
open: boolean;
|
||||
tab: ProjectSettingsDialogTab;
|
||||
};
|
||||
export const projectSettingsDialogAtom = atom(
|
||||
{ open: false, tab: "secrets" } as ProjectSettingsDialogState,
|
||||
(_, set, update?: Partial<ProjectSettingsDialogState>) => {
|
||||
set(projectSettingsDialogAtom, {
|
||||
open: true,
|
||||
tab: "secrets",
|
||||
...update,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Store the current ID of the table being edited in tableSettingsDialog
|
||||
* to derive tableSettingsDialogSchemaAtom
|
||||
@@ -147,10 +167,6 @@ export const tableSettingsDialogSchemaAtom = atom(async (get) => {
|
||||
/** Open the Get Started checklist from anywhere */
|
||||
export const getStartedChecklistAtom = atom(false);
|
||||
|
||||
/** Persist the state of the add row ID type */
|
||||
export const tableAddRowIdTypeAtom = atomWithStorage<
|
||||
"decrement" | "random" | "custom"
|
||||
>("__ROWY__ADD_ROW_ID_TYPE", "decrement");
|
||||
/** Persist when the user dismissed the row out of order warning */
|
||||
export const tableOutOfOrderDismissedAtom = atomWithStorage(
|
||||
"__ROWY__OUT_OF_ORDER_TOOLTIP_DISMISSED",
|
||||
|
||||
@@ -30,6 +30,12 @@ export const themeOverriddenAtom = atomWithStorage(
|
||||
false
|
||||
);
|
||||
|
||||
/** User's default table settings (affecting saving and popup behavior) */
|
||||
export const defaultTableSettingsAtom = atom((get) => {
|
||||
const userSettings = get(userSettingsAtom);
|
||||
return userSettings.defaultTableSettings;
|
||||
});
|
||||
|
||||
/** Customized base theme based on project and user settings */
|
||||
export const customizedThemesAtom = atom((get) => {
|
||||
const publicSettings = get(publicSettingsAtom);
|
||||
|
||||
@@ -494,7 +494,11 @@ describe("deleteRow", () => {
|
||||
} = renderHook(() => useSetAtom(deleteRowAtom, tableScope));
|
||||
expect(deleteRow).toBeDefined();
|
||||
|
||||
await act(() => deleteRow(TEST_COLLECTION + "/row2"));
|
||||
await act(() =>
|
||||
deleteRow({
|
||||
path: TEST_COLLECTION + "/row2",
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
result: { current: tableRows },
|
||||
@@ -510,7 +514,11 @@ describe("deleteRow", () => {
|
||||
} = renderHook(() => useSetAtom(deleteRowAtom, tableScope));
|
||||
expect(deleteRow).toBeDefined();
|
||||
|
||||
await act(() => deleteRow(TEST_COLLECTION + "/rowLocal2"));
|
||||
await act(() =>
|
||||
deleteRow({
|
||||
path: TEST_COLLECTION + "/rowLocal2",
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
result: { current: tableRows },
|
||||
@@ -527,9 +535,9 @@ describe("deleteRow", () => {
|
||||
expect(deleteRow).toBeDefined();
|
||||
|
||||
await act(() =>
|
||||
deleteRow(
|
||||
["row1", "row2", "row8"].map((id) => TEST_COLLECTION + "/" + id)
|
||||
)
|
||||
deleteRow({
|
||||
path: ["row1", "row2", "row8"].map((id) => TEST_COLLECTION + "/" + id),
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -548,7 +556,11 @@ describe("deleteRow", () => {
|
||||
} = renderHook(() => useSetAtom(deleteRowAtom, tableScope));
|
||||
expect(deleteRow).toBeDefined();
|
||||
|
||||
await act(() => deleteRow(generatedRows.map((row) => row._rowy_ref.path)));
|
||||
await act(() =>
|
||||
deleteRow({
|
||||
path: generatedRows.map((row) => row._rowy_ref.path),
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
result: { current: tableRows },
|
||||
@@ -563,7 +575,11 @@ describe("deleteRow", () => {
|
||||
} = renderHook(() => useSetAtom(deleteRowAtom, tableScope));
|
||||
expect(deleteRow).toBeDefined();
|
||||
|
||||
await act(() => deleteRow("nonExistent"));
|
||||
await act(() =>
|
||||
deleteRow({
|
||||
path: "nonExistent",
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
result: { current: tableRows },
|
||||
@@ -578,7 +594,11 @@ describe("deleteRow", () => {
|
||||
} = renderHook(() => useSetAtom(deleteRowAtom, tableScope));
|
||||
expect(deleteRow).toBeDefined();
|
||||
|
||||
await act(() => deleteRow("nonExistent"));
|
||||
await act(() =>
|
||||
deleteRow({
|
||||
path: "nonExistent",
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
result: { current: tableRows },
|
||||
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
_bulkWriteDbAtom,
|
||||
} from "./table";
|
||||
|
||||
import { TableRow, BulkWriteFunction } from "@src/types/table";
|
||||
import {
|
||||
TableRow,
|
||||
BulkWriteFunction,
|
||||
ArrayTableRowData,
|
||||
} from "@src/types/table";
|
||||
import {
|
||||
rowyUser,
|
||||
generateId,
|
||||
@@ -211,7 +215,17 @@ export const addRowAtom = atom(
|
||||
*/
|
||||
export const deleteRowAtom = atom(
|
||||
null,
|
||||
async (get, set, path: string | string[]) => {
|
||||
async (
|
||||
get,
|
||||
set,
|
||||
{
|
||||
path,
|
||||
options,
|
||||
}: {
|
||||
path: string | string[];
|
||||
options?: ArrayTableRowData;
|
||||
}
|
||||
) => {
|
||||
const deleteRowDb = get(_deleteRowDbAtom);
|
||||
if (!deleteRowDb) throw new Error("Cannot write to database");
|
||||
|
||||
@@ -223,9 +237,9 @@ export const deleteRowAtom = atom(
|
||||
find(tableRowsLocal, ["_rowy_ref.path", path])
|
||||
);
|
||||
if (isLocalRow) set(tableRowsLocalAtom, { type: "delete", path });
|
||||
|
||||
// Always delete from db in case it exists
|
||||
await deleteRowDb(path);
|
||||
// *options* are passed in case of array table to target specific row
|
||||
await deleteRowDb(path, options);
|
||||
if (auditChange) auditChange("DELETE_ROW", path);
|
||||
};
|
||||
|
||||
@@ -242,10 +256,15 @@ export interface IBulkAddRowsOptions {
|
||||
rows: Partial<TableRow[]>;
|
||||
collection: string;
|
||||
onBatchCommit?: Parameters<BulkWriteFunction>[1];
|
||||
type?: "add";
|
||||
}
|
||||
export const bulkAddRowsAtom = atom(
|
||||
null,
|
||||
async (get, _, { rows, collection, onBatchCommit }: IBulkAddRowsOptions) => {
|
||||
async (
|
||||
get,
|
||||
_,
|
||||
{ rows, collection, onBatchCommit, type }: IBulkAddRowsOptions
|
||||
) => {
|
||||
const bulkWriteDb = get(_bulkWriteDbAtom);
|
||||
if (!bulkWriteDb) throw new Error("Cannot write to database");
|
||||
const tableSettings = get(tableSettingsAtom);
|
||||
@@ -277,7 +296,11 @@ export const bulkAddRowsAtom = atom(
|
||||
|
||||
// Assign a random ID to each row
|
||||
const operations = rows.map((row) => ({
|
||||
type: row?._rowy_ref?.id ? ("update" as "update") : ("add" as "add"),
|
||||
type: type
|
||||
? type
|
||||
: row?._rowy_ref?.id
|
||||
? ("update" as "update")
|
||||
: ("add" as "add"),
|
||||
path: `${collection}/${row?._rowy_ref?.id ?? generateId()}`,
|
||||
data: { ...initialValues, ...omitRowyFields(row) },
|
||||
}));
|
||||
@@ -312,6 +335,8 @@ export interface IUpdateFieldOptions {
|
||||
useArrayUnion?: boolean;
|
||||
/** Optionally, uses firestore's arrayRemove with the given value. Removes given value items from the existing array */
|
||||
useArrayRemove?: boolean;
|
||||
/** Optionally, used to locate the row in ArraySubTable. */
|
||||
arrayTableData?: ArrayTableRowData;
|
||||
}
|
||||
/**
|
||||
* Set function updates or deletes a field in a row.
|
||||
@@ -339,6 +364,7 @@ export const updateFieldAtom = atom(
|
||||
disableCheckEquality,
|
||||
useArrayUnion,
|
||||
useArrayRemove,
|
||||
arrayTableData,
|
||||
}: IUpdateFieldOptions
|
||||
) => {
|
||||
const updateRowDb = get(_updateRowDbAtom);
|
||||
@@ -352,9 +378,17 @@ export const updateFieldAtom = atom(
|
||||
const tableRows = get(tableRowsAtom);
|
||||
const tableRowsLocal = get(tableRowsLocalAtom);
|
||||
|
||||
const row = find(tableRows, ["_rowy_ref.path", path]);
|
||||
const row = find(
|
||||
tableRows,
|
||||
arrayTableData?.index !== undefined
|
||||
? ["_rowy_ref.arrayTableData.index", arrayTableData?.index]
|
||||
: ["_rowy_ref.path", path]
|
||||
);
|
||||
|
||||
if (!row) throw new Error("Could not find row");
|
||||
const isLocalRow = Boolean(find(tableRowsLocal, ["_rowy_ref.path", path]));
|
||||
const isLocalRow =
|
||||
fieldName.startsWith("_rowy_formulaValue_") ||
|
||||
Boolean(find(tableRowsLocal, ["_rowy_ref.path", path]));
|
||||
|
||||
const update: Partial<TableRow> = {};
|
||||
|
||||
@@ -387,7 +421,12 @@ export const updateFieldAtom = atom(
|
||||
...(row[fieldName] ?? []),
|
||||
...localUpdate[fieldName],
|
||||
];
|
||||
dbUpdate[fieldName] = arrayUnion(...dbUpdate[fieldName]);
|
||||
// if we are updating a row of ArraySubTable
|
||||
if (arrayTableData?.index !== undefined) {
|
||||
dbUpdate[fieldName] = localUpdate[fieldName];
|
||||
} else {
|
||||
dbUpdate[fieldName] = arrayUnion(...dbUpdate[fieldName]);
|
||||
}
|
||||
}
|
||||
|
||||
//apply arrayRemove
|
||||
@@ -400,8 +439,15 @@ export const updateFieldAtom = atom(
|
||||
row[fieldName] ?? [],
|
||||
(el) => !find(localUpdate[fieldName], el)
|
||||
);
|
||||
dbUpdate[fieldName] = arrayRemove(...dbUpdate[fieldName]);
|
||||
|
||||
// if we are updating a row of ArraySubTable
|
||||
if (arrayTableData?.index !== undefined) {
|
||||
dbUpdate[fieldName] = localUpdate[fieldName];
|
||||
} else {
|
||||
dbUpdate[fieldName] = arrayRemove(...dbUpdate[fieldName]);
|
||||
}
|
||||
}
|
||||
// need to pass the index of the row to updateRowDb
|
||||
|
||||
// Check for required fields
|
||||
const newRowValues = updateRowData(cloneDeep(row), dbUpdate);
|
||||
@@ -423,6 +469,14 @@ export const updateFieldAtom = atom(
|
||||
deleteFields: deleteField ? [fieldName] : [],
|
||||
});
|
||||
|
||||
// TODO(han): Formula field persistence
|
||||
// const config = find(tableColumnsOrdered, (c) => {
|
||||
// const [, key] = fieldName.split("_rowy_formulaValue_");
|
||||
// return c.key === key;
|
||||
// });
|
||||
// if(!config.persist) return;
|
||||
if (fieldName.startsWith("_rowy_formulaValue")) return;
|
||||
|
||||
// If it has no missingRequiredFields, also write to db
|
||||
// And write entire row to handle the case where it doesn’t exist in db yet
|
||||
if (missingRequiredFields.length === 0) {
|
||||
@@ -431,7 +485,8 @@ export const updateFieldAtom = atom(
|
||||
await updateRowDb(
|
||||
row._rowy_ref.path,
|
||||
omitRowyFields(newRowValues),
|
||||
deleteField ? [fieldName] : []
|
||||
deleteField ? [fieldName] : [],
|
||||
arrayTableData
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -440,7 +495,8 @@ export const updateFieldAtom = atom(
|
||||
await updateRowDb(
|
||||
row._rowy_ref.path,
|
||||
omitRowyFields(dbUpdate),
|
||||
deleteField ? [fieldName] : []
|
||||
deleteField ? [fieldName] : [],
|
||||
arrayTableData
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
BulkWriteFunction,
|
||||
} from "@src/types/table";
|
||||
import { updateRowData } from "@src/utils/table";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
|
||||
/** Root atom from which others are derived */
|
||||
export const tableIdAtom = atom("");
|
||||
@@ -47,6 +48,8 @@ export const tableColumnsOrderedAtom = atom<ColumnConfig[]>((get) => {
|
||||
["desc", "asc"]
|
||||
);
|
||||
});
|
||||
/** Store the table */
|
||||
export const reactTableAtom = atom<Table<TableRow> | null>(null);
|
||||
/** Reducer function to convert from array of columns to columns object */
|
||||
export const tableColumnsReducer = (
|
||||
a: Record<string, ColumnConfig>,
|
||||
@@ -59,6 +62,8 @@ export const tableColumnsReducer = (
|
||||
|
||||
/** Filters applied to the local view */
|
||||
export const tableFiltersAtom = atom<TableFilter[]>([]);
|
||||
/** Join operator applied to mulitple filters */
|
||||
export const tableFiltersJoinAtom = atom<"AND" | "OR">("AND");
|
||||
/** Sorts applied to the local view */
|
||||
export const tableSortsAtom = atom<TableSort[]>([]);
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const columnMenuAtom = atom<{
|
||||
* ```
|
||||
*/
|
||||
export const columnModalAtom = atomWithHash<{
|
||||
type: "new" | "name" | "type" | "config";
|
||||
type: "new" | "name" | "type" | "config" | "setColumnWidth";
|
||||
columnKey?: string;
|
||||
index?: number;
|
||||
} | null>("columnModal", null, { replaceState: true });
|
||||
@@ -134,6 +134,7 @@ export type SelectedCell = {
|
||||
path: string | "_rowy_header";
|
||||
columnKey: string | "_rowy_row_actions";
|
||||
focusInside: boolean;
|
||||
arrayIndex?: number; // for array sub table
|
||||
};
|
||||
/** Store selected cell in table. Used in side drawer and context menu */
|
||||
export const selectedCellAtom = atom<SelectedCell | null>(null);
|
||||
|
||||
@@ -14,6 +14,7 @@ import FullScreenButton from "@src/components/FullScreenButton";
|
||||
import { spreadSx } from "@src/utils/ui";
|
||||
import githubLightTheme from "@src/components/CodeEditor/github-light-default.json";
|
||||
import githubDarkTheme from "@src/components/CodeEditor/github-dark-default.json";
|
||||
import { AutoTypings, LocalStorageCache } from "monaco-editor-auto-typings";
|
||||
|
||||
export interface ICodeEditorProps
|
||||
extends Partial<EditorProps>,
|
||||
@@ -47,7 +48,7 @@ export default function CodeEditor({
|
||||
extraLibs,
|
||||
diagnosticsOptions,
|
||||
onUnmount,
|
||||
defaultLanguage = "javascript",
|
||||
defaultLanguage = "typescript",
|
||||
...props
|
||||
}: ICodeEditorProps) {
|
||||
const theme = useTheme();
|
||||
@@ -131,11 +132,22 @@ export default function CodeEditor({
|
||||
});
|
||||
});
|
||||
}}
|
||||
onMount={(editor) => {
|
||||
{...props}
|
||||
onMount={async (editor, monaco) => {
|
||||
if (props.onMount) {
|
||||
props.onMount(editor, monaco);
|
||||
}
|
||||
if (onFocus) editor.onDidFocusEditorWidget(onFocus);
|
||||
if (onBlur) editor.onDidBlurEditorWidget(onBlur);
|
||||
const autoTypings = await AutoTypings.create(editor, {
|
||||
monaco: monaco,
|
||||
sourceCache: new LocalStorageCache(),
|
||||
debounceDuration: 500, // ms
|
||||
onError: (e) => {
|
||||
console.log("Auto typing error", e);
|
||||
},
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
onValidate={onValidate_}
|
||||
theme={`github-${theme.palette.mode}`}
|
||||
options={{
|
||||
@@ -149,6 +161,7 @@ export default function CodeEditor({
|
||||
fixedOverflowWidgets: true,
|
||||
tabSize: 2,
|
||||
...props.options,
|
||||
language: "typescript",
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
11
src/components/CodeEditor/extensions.d.ts
vendored
11
src/components/CodeEditor/extensions.d.ts
vendored
@@ -128,4 +128,15 @@ type PushNotificationRequest = {
|
||||
type PushNotificationBody = (
|
||||
context: ExtensionContext
|
||||
) => Message | Message[] | Promise<Message | Message[]>;
|
||||
|
||||
type TaskBody = (context: ExtensionContext) => Promise<any>;
|
||||
|
||||
type BuildshipAuthenticatedTriggerBody = (
|
||||
context: ExtensionContext
|
||||
) => Promise<{
|
||||
buildshipConfig: {
|
||||
projectId: string;
|
||||
workflowId: string;
|
||||
};
|
||||
body: string;
|
||||
}>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { matchSorter } from "match-sorter";
|
||||
|
||||
import {
|
||||
tableScope,
|
||||
@@ -12,15 +13,13 @@ import type { languages } from "monaco-editor/esm/vs/editor/editor.api";
|
||||
import { useTheme } from "@mui/material";
|
||||
import type { SystemStyleObject, Theme } from "@mui/system";
|
||||
|
||||
/* eslint-disable import/no-webpack-loader-syntax */
|
||||
import firestoreDefs from "!!raw-loader!./firestore.d.ts";
|
||||
import firebaseAuthDefs from "!!raw-loader!./firebaseAuth.d.ts";
|
||||
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 { runRoutes } from "@src/constants/runRoutes";
|
||||
import { rowyRunAtom, projectScope } from "@src/atoms/projectScope";
|
||||
import firestoreDefs from "./firestore.d.ts?raw";
|
||||
import firebaseAuthDefs from "./firebaseAuth.d.ts?raw";
|
||||
import firebaseStorageDefs from "./firebaseStorage.d.ts?raw";
|
||||
import utilsDefs from "./utils.d.ts?raw";
|
||||
import rowyUtilsDefs from "./rowy.d.ts?raw";
|
||||
import extensionsDefs from "./extensions.d.ts?raw";
|
||||
import { projectScope, secretNamesAtom } from "@src/atoms/projectScope";
|
||||
import { getFieldProp } from "@src/components/fields";
|
||||
|
||||
export interface IUseMonacoCustomizationsProps {
|
||||
@@ -53,8 +52,8 @@ export default function useMonacoCustomizations({
|
||||
const theme = useTheme();
|
||||
const monaco = useMonaco();
|
||||
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
|
||||
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
|
||||
const [secretNames] = useAtom(secretNamesAtom, projectScope);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -66,26 +65,26 @@ export default function useMonacoCustomizations({
|
||||
if (!monaco) return;
|
||||
|
||||
try {
|
||||
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
moduleResolution:
|
||||
monaco.languages.typescript.ModuleResolutionKind.NodeJs,
|
||||
module: monaco.languages.typescript.ModuleKind.CommonJS,
|
||||
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
||||
allowNonTsExtensions: true,
|
||||
typeRoots: ["node_modules/@types"],
|
||||
});
|
||||
monaco.languages.typescript.javascriptDefaults.addExtraLib(firestoreDefs);
|
||||
monaco.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
monaco.languages.typescript.typescriptDefaults.addExtraLib(firestoreDefs);
|
||||
monaco.languages.typescript.typescriptDefaults.addExtraLib(
|
||||
firebaseAuthDefs
|
||||
);
|
||||
monaco.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
monaco.languages.typescript.typescriptDefaults.addExtraLib(
|
||||
firebaseStorageDefs
|
||||
);
|
||||
monaco.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
monaco.languages.typescript.typescriptDefaults.addExtraLib(
|
||||
utilsDefs,
|
||||
"ts:filename/utils.d.ts"
|
||||
);
|
||||
monaco.languages.typescript.javascriptDefaults.addExtraLib(rowyUtilsDefs);
|
||||
|
||||
setLoggingReplacementActions();
|
||||
monaco.languages.typescript.typescriptDefaults.addExtraLib(rowyUtilsDefs);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"An error occurred during initialization of Monaco: ",
|
||||
@@ -99,7 +98,7 @@ export default function useMonacoCustomizations({
|
||||
if (!monaco) return;
|
||||
if (!extraLibs) return;
|
||||
try {
|
||||
monaco.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
monaco.languages.typescript.typescriptDefaults.addExtraLib(
|
||||
extraLibs.join("\n"),
|
||||
"ts:filename/extraLibs.d.ts"
|
||||
);
|
||||
@@ -114,7 +113,7 @@ export default function useMonacoCustomizations({
|
||||
if (!monaco) return;
|
||||
|
||||
try {
|
||||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||||
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
|
||||
...JSON.parse(stringifiedDiagnosticsOptions),
|
||||
diagnosticCodesToIgnore: [
|
||||
1323, // remove dynamic import error
|
||||
@@ -126,13 +125,13 @@ export default function useMonacoCustomizations({
|
||||
}
|
||||
}, [monaco, stringifiedDiagnosticsOptions]);
|
||||
|
||||
const setLoggingReplacementActions = () => {
|
||||
const setReplacementActions = () => {
|
||||
if (!monaco) return;
|
||||
const { dispose } = monaco.languages.registerCodeActionProvider(
|
||||
"javascript",
|
||||
{
|
||||
provideCodeActions: (model, range, context, token) => {
|
||||
const actions = context.markers
|
||||
const consoleLogReplacements = context.markers
|
||||
.filter((error) => {
|
||||
return error.message.includes("Rowy Cloud Logging");
|
||||
})
|
||||
@@ -158,8 +157,63 @@ export default function useMonacoCustomizations({
|
||||
isPreferred: true,
|
||||
};
|
||||
});
|
||||
const secretNameReplacements = context.markers
|
||||
.filter((error) => {
|
||||
return error.message.includes(
|
||||
"is not assignable to parameter of type 'SecretNames'"
|
||||
);
|
||||
})
|
||||
.map((error) => {
|
||||
const typoSecretName = model
|
||||
.getLineContent(error.startLineNumber)
|
||||
.slice(error.startColumn, error.endColumn - 2);
|
||||
const similarSecretNames =
|
||||
matchSorter(secretNames.secretNames ?? [], typoSecretName) ??
|
||||
[];
|
||||
const otherSecretNames =
|
||||
secretNames.secretNames?.filter(
|
||||
(secretName) => !similarSecretNames.includes(secretName)
|
||||
) ?? [];
|
||||
return [
|
||||
...similarSecretNames.map((secretName) => ({
|
||||
title: `Replace with "${secretName}"`,
|
||||
diagnostics: [error],
|
||||
kind: "quickfix",
|
||||
edit: {
|
||||
edits: [
|
||||
{
|
||||
resource: model.uri,
|
||||
edit: {
|
||||
range: error,
|
||||
text: `"${secretName}"`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isPreferred: true,
|
||||
})),
|
||||
...otherSecretNames.map((secretName) => ({
|
||||
title: `Replace with "${secretName}"`,
|
||||
diagnostics: [error],
|
||||
kind: "quickfix",
|
||||
edit: {
|
||||
edits: [
|
||||
{
|
||||
resource: model.uri,
|
||||
edit: {
|
||||
range: error,
|
||||
text: `"${secretName}"`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isPreferred: false,
|
||||
})),
|
||||
];
|
||||
})
|
||||
.flat();
|
||||
return {
|
||||
actions: actions,
|
||||
actions: [...consoleLogReplacements, ...secretNameReplacements],
|
||||
dispose: () => {},
|
||||
};
|
||||
},
|
||||
@@ -171,7 +225,6 @@ export default function useMonacoCustomizations({
|
||||
dispose();
|
||||
});
|
||||
};
|
||||
|
||||
const addJsonFieldDefinition = async (
|
||||
columnKey: string,
|
||||
interfaceName: string
|
||||
@@ -180,11 +233,11 @@ export default function useMonacoCustomizations({
|
||||
.map((row) => row[columnKey])
|
||||
.filter((entry) => entry !== undefined)
|
||||
.map((entry) => JSON.stringify(entry));
|
||||
monaco?.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
monaco?.languages.typescript.typescriptDefaults.addExtraLib(
|
||||
`type ${interfaceName} = any;`
|
||||
);
|
||||
// if (!samples || samples.length === 0) {
|
||||
// monaco?.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
// monaco?.languages.typescript.typescriptDefaults.addExtraLib(
|
||||
// `type ${interfaceName} = any;`
|
||||
// );
|
||||
// return;
|
||||
@@ -202,30 +255,10 @@ export default function useMonacoCustomizations({
|
||||
// rendererOptions: { "just-types": "true" },
|
||||
// });
|
||||
// const newLib = result.lines.join("\n").replaceAll("export ", "");
|
||||
// monaco?.languages.typescript.javascriptDefaults.addExtraLib(newLib);
|
||||
// monaco?.languages.typescript.typescriptDefaults.addExtraLib(newLib);
|
||||
//}
|
||||
};
|
||||
|
||||
const setSecrets = async () => {
|
||||
// set secret options
|
||||
try {
|
||||
const listSecrets = await rowyRun({
|
||||
route: runRoutes.listSecrets,
|
||||
});
|
||||
const secretsDef = `type SecretNames = ${listSecrets
|
||||
.map((secret: string) => `"${secret}"`)
|
||||
.join(" | ")}
|
||||
enum secrets {
|
||||
${listSecrets
|
||||
.map((secret: string) => `${secret} = "${secret}"`)
|
||||
.join("\n")}
|
||||
}
|
||||
`;
|
||||
monaco?.languages.typescript.javascriptDefaults.addExtraLib(secretsDef);
|
||||
} catch (error) {
|
||||
console.error("Could not set secret definitions: ", error);
|
||||
}
|
||||
};
|
||||
//TODO: types
|
||||
const setBaseDefinitions = () => {
|
||||
const rowDefinition =
|
||||
@@ -245,13 +278,13 @@ export default function useMonacoCustomizations({
|
||||
.map((key) => `"${key}"`)
|
||||
.join("|\n");
|
||||
|
||||
monaco?.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
monaco?.languages.typescript.typescriptDefaults.addExtraLib(
|
||||
["/**", " * extensions type configuration", " */", extensionsDefs].join(
|
||||
"\n"
|
||||
),
|
||||
"ts:filename/extensions.d.ts"
|
||||
);
|
||||
monaco?.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
monaco?.languages.typescript.typescriptDefaults.addExtraLib(
|
||||
[
|
||||
"// basic types that are used in all places",
|
||||
"declare var require: any;",
|
||||
@@ -275,14 +308,26 @@ export default function useMonacoCustomizations({
|
||||
} catch (error) {
|
||||
console.error("Could not set basic", error);
|
||||
}
|
||||
// set available secrets from secretManager
|
||||
try {
|
||||
setSecrets();
|
||||
} catch (error) {
|
||||
console.error("Could not set secrets: ", error);
|
||||
}
|
||||
}, [monaco, tableColumnsOrdered]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
if (secretNames.loading) return;
|
||||
if (!secretNames.secretNames) return;
|
||||
const secretsDef = `type SecretNames = ${secretNames.secretNames
|
||||
.map((secret: string) => `"${secret}"`)
|
||||
.join(" | ")} \n
|
||||
enum secrets {
|
||||
${secretNames.secretNames
|
||||
.map((secret: string) => `"${secret}" = "${secret}"`)
|
||||
.join("\n")}
|
||||
}
|
||||
`;
|
||||
monaco?.languages.typescript.javascriptDefaults.addExtraLib(secretsDef);
|
||||
|
||||
setReplacementActions();
|
||||
}, [monaco, secretNames]);
|
||||
|
||||
let boxSx: SystemStyleObject<Theme> = {
|
||||
minWidth: 400,
|
||||
minHeight,
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function ColorPickerInput({
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [width, setRef] = useResponsiveWidth();
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark" ? true : false;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -61,6 +62,9 @@ export default function ColorPickerInput({
|
||||
boxSizing: "unset",
|
||||
},
|
||||
},
|
||||
".rcp-dark": {
|
||||
"--rcp-background": "transparent",
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -70,6 +74,7 @@ export default function ColorPickerInput({
|
||||
color={localValue}
|
||||
onChange={(color) => setLocalValue(color)}
|
||||
onChangeComplete={onChangeComplete}
|
||||
dark={isDark}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@src/assets/icons";
|
||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
import StraightenIcon from "@mui/icons-material/Straighten";
|
||||
import EditIcon from "@mui/icons-material/EditOutlined";
|
||||
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
|
||||
import EvalIcon from "@mui/icons-material/PlayCircleOutline";
|
||||
@@ -59,6 +60,7 @@ import { getFieldProp } from "@src/components/fields";
|
||||
import { analytics, logEvent } from "@src/analytics";
|
||||
import {
|
||||
formatSubTableName,
|
||||
generateId,
|
||||
getTableBuildFunctionPathname,
|
||||
getTableSchemaPath,
|
||||
} from "@src/utils/table";
|
||||
@@ -192,9 +194,10 @@ export default function ColumnMenu({
|
||||
setTableSorts(
|
||||
isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }]
|
||||
);
|
||||
if (!isSorted || isAsc) {
|
||||
triggerSaveTableSorts([{ key: sortKey, direction: "desc" }]);
|
||||
}
|
||||
|
||||
triggerSaveTableSorts(
|
||||
isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }]
|
||||
);
|
||||
handleClose();
|
||||
},
|
||||
active: isSorted && !isAsc,
|
||||
@@ -209,9 +212,9 @@ export default function ColumnMenu({
|
||||
setTableSorts(
|
||||
isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }]
|
||||
);
|
||||
if (!isSorted || !isAsc) {
|
||||
triggerSaveTableSorts([{ key: sortKey, direction: "asc" }]);
|
||||
}
|
||||
triggerSaveTableSorts(
|
||||
isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }]
|
||||
);
|
||||
handleClose();
|
||||
},
|
||||
active: isSorted && isAsc,
|
||||
@@ -250,6 +253,7 @@ export default function ColumnMenu({
|
||||
: column.type
|
||||
)!.operators[0]?.value || "==",
|
||||
value: "",
|
||||
id: generateId(),
|
||||
},
|
||||
});
|
||||
handleClose();
|
||||
@@ -262,6 +266,16 @@ export default function ColumnMenu({
|
||||
: column.type
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "setColumnWidth",
|
||||
label: "Set Column Width",
|
||||
icon: <StraightenIcon />,
|
||||
onClick: () => {
|
||||
openColumnModal({ type: "setColumnWidth", columnKey: column.key });
|
||||
handleClose();
|
||||
},
|
||||
disabled: column.resizable === false,
|
||||
},
|
||||
];
|
||||
|
||||
const configActions: IMenuContentsProps["menuItems"] = [
|
||||
@@ -418,7 +432,7 @@ export default function ColumnMenu({
|
||||
key: "insertLeft",
|
||||
icon: <ColumnPlusBeforeIcon />,
|
||||
onClick: () => {
|
||||
openColumnModal({ type: "new", index: column.index - 1 });
|
||||
openColumnModal({ type: "new", index: column.index });
|
||||
handleClose();
|
||||
},
|
||||
disabled: !canAddColumns,
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function ColumnConfigModal({
|
||||
) {
|
||||
setShowRebuildPrompt(true);
|
||||
}
|
||||
const updatedConfig = set({ ...newConfig }, key, update);
|
||||
const updatedConfig = set(newConfig, key, update); // Modified by @devsgnr, spread operator `{...newConfig}` instead of just `newConfig` was preventing multiple calls from running properly
|
||||
setNewConfig(updatedConfig);
|
||||
validateSettings();
|
||||
};
|
||||
|
||||
@@ -11,8 +11,7 @@ import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
import { WIKI_LINKS } from "@src/constants/externalLinks";
|
||||
|
||||
/* eslint-disable import/no-webpack-loader-syntax */
|
||||
import defaultValueDefs from "!!raw-loader!./defaultValue.d.ts";
|
||||
import defaultValueDefs from "./defaultValue.d.ts?raw";
|
||||
import {
|
||||
projectScope,
|
||||
compatibleRowyRunVersionAtom,
|
||||
@@ -54,22 +53,24 @@ function CodeEditor({ type, column, handleChange }: ICodeEditorProps) {
|
||||
dynamicValueFn = column.config?.defaultValue?.dynamicValueFn;
|
||||
} else if (column.config?.defaultValue?.script) {
|
||||
dynamicValueFn = `const dynamicValueFn: DefaultValue = async ({row,ref,db,storage,auth,logging})=>{
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("dynamicValueFn started")
|
||||
|
||||
${column.config?.defaultValue.script}
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`;
|
||||
} else {
|
||||
dynamicValueFn = `const dynamicValueFn: DefaultValue = async ({row,ref,db,storage,auth,logging})=>{
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("dynamicValueFn started")
|
||||
|
||||
dynamicValueFn = `// Import any NPM package needed
|
||||
// import _ from "lodash";
|
||||
|
||||
const defaultValue: DefaultValue = async ({ row, ref, db, storage, auth, logging }) => {
|
||||
logging.log("dynamicValueFn started");
|
||||
|
||||
// Example: generate random hex color
|
||||
// const color = "#" + Math.floor(Math.random() * 16777215).toString(16);
|
||||
// return color;
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`;
|
||||
};
|
||||
|
||||
export default defaultValue;
|
||||
`;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import NewColumnModal from "./NewColumnModal";
|
||||
import NameChangeModal from "./NameChangeModal";
|
||||
import TypeChangeModal from "./TypeChangeModal";
|
||||
import ColumnConfigModal from "./ColumnConfigModal";
|
||||
import SetColumnWidthModal from "./SetColumnWidthModal";
|
||||
|
||||
import {
|
||||
tableScope,
|
||||
@@ -40,5 +41,8 @@ export default function ColumnModals() {
|
||||
if (columnModal.type === "config")
|
||||
return <ColumnConfigModal onClose={onClose} column={column} />;
|
||||
|
||||
if (columnModal.type === "setColumnWidth")
|
||||
return <SetColumnWidthModal onClose={onClose} column={column} />;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
projectSettingsAtom,
|
||||
rowyRunModalAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope";
|
||||
|
||||
export interface IFieldsDropdownProps {
|
||||
value: FieldType | "";
|
||||
@@ -35,17 +36,22 @@ export default function FieldsDropdown({
|
||||
}: IFieldsDropdownProps) {
|
||||
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
|
||||
const openRowyRunModal = useSetAtom(rowyRunModalAtom, projectScope);
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
const fieldTypesToDisplay = optionsProp
|
||||
? FIELDS.filter((fieldConfig) => optionsProp.indexOf(fieldConfig.type) > -1)
|
||||
: FIELDS;
|
||||
const options = fieldTypesToDisplay.map((fieldConfig) => {
|
||||
const requireCloudFunctionSetup =
|
||||
fieldConfig.requireCloudFunction && !projectSettings.rowyRunUrl;
|
||||
const requireCollectionTable =
|
||||
tableSettings.isCollection === false &&
|
||||
fieldConfig.requireCollectionTable === true;
|
||||
return {
|
||||
label: fieldConfig.name,
|
||||
value: fieldConfig.type,
|
||||
disabled: requireCloudFunctionSetup,
|
||||
disabled: requireCloudFunctionSetup || requireCollectionTable,
|
||||
requireCloudFunctionSetup,
|
||||
requireCollectionTable,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -82,7 +88,18 @@ export default function FieldsDropdown({
|
||||
{getFieldProp("icon", option.value as FieldType)}
|
||||
</ListItemIcon>
|
||||
<Typography>{option.label}</Typography>
|
||||
{option.requireCloudFunctionSetup && (
|
||||
{option.requireCollectionTable ? (
|
||||
<Typography
|
||||
color="error"
|
||||
variant="inherit"
|
||||
component="span"
|
||||
marginLeft={1}
|
||||
className={"require-cloud-function"}
|
||||
>
|
||||
{" "}
|
||||
Unavailable
|
||||
</Typography>
|
||||
) : option.requireCloudFunctionSetup ? (
|
||||
<Typography
|
||||
color="error"
|
||||
variant="inherit"
|
||||
@@ -107,7 +124,7 @@ export default function FieldsDropdown({
|
||||
Cloud Function
|
||||
</span>
|
||||
</Typography>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
label={label || "Field type"}
|
||||
|
||||
70
src/components/ColumnModals/SetColumnWidthModal.tsx
Normal file
70
src/components/ColumnModals/SetColumnWidthModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { IColumnModalProps } from ".";
|
||||
import { reactTableAtom } from "@src/atoms/tableScope";
|
||||
import { tableScope } from "@src/atoms/tableScope";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import { TextField } from "@mui/material";
|
||||
import Modal from "@src/components/Modal";
|
||||
|
||||
export default function SetColumnWidthModal({
|
||||
onClose,
|
||||
column,
|
||||
}: IColumnModalProps) {
|
||||
const [reactTable] = useAtom(reactTableAtom, tableScope);
|
||||
const [newWidth, setWidth] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Set the initial width to the current column width once the table is fetched.
|
||||
setWidth(reactTable?.getAllColumns()[column.index].getSize() || 0);
|
||||
}, [reactTable, column]);
|
||||
|
||||
const handleUpdate = () => {
|
||||
reactTable?.setColumnSizing((old) => {
|
||||
const newSizing = { ...old };
|
||||
// Set the new width for the column.
|
||||
newSizing[column.fieldName] = newWidth;
|
||||
return newSizing;
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
title="Set Column Width"
|
||||
maxWidth="xs"
|
||||
children={
|
||||
<form
|
||||
id="column-width-modal"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleUpdate();
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
value={newWidth}
|
||||
autoFocus
|
||||
variant="filled"
|
||||
id="name"
|
||||
label="Width"
|
||||
type="number"
|
||||
fullWidth
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
actions={{
|
||||
primary: {
|
||||
children: "Update",
|
||||
type: "submit",
|
||||
form: "column-width-modal",
|
||||
},
|
||||
secondary: {
|
||||
onClick: onClose,
|
||||
children: "Cancel",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,39 @@
|
||||
import { Chip, ChipProps } from "@mui/material";
|
||||
import palette, { paletteToMui } from "@src/theme/palette";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { isEqual } from "lodash-es";
|
||||
|
||||
export const VARIANTS = ["yes", "no", "maybe"] as const;
|
||||
const paletteColor = {
|
||||
yes: "success",
|
||||
maybe: "warning",
|
||||
no: "error",
|
||||
yes: paletteToMui(palette.green),
|
||||
maybe: paletteToMui(palette.yellow),
|
||||
no: paletteToMui(palette.aRed),
|
||||
} as const;
|
||||
|
||||
// TODO: Create a more generalised solution for this
|
||||
// Switched to a more generalized solution - adding backwards compatibility to maintain [Yes, No, Maybe] colors even if no color is selected
|
||||
// Modified by @devsgnr
|
||||
export default function FormattedChip(props: ChipProps) {
|
||||
const defaultColor = paletteToMui(palette.aGray);
|
||||
const { mode } = useTheme().palette;
|
||||
const fallback = { backgroundColor: defaultColor[mode] };
|
||||
const { sx, ...newProps } = props;
|
||||
|
||||
const label =
|
||||
typeof props.label === "string" ? props.label.toLowerCase() : "";
|
||||
const inVariant = VARIANTS.includes(label as any);
|
||||
|
||||
if (VARIANTS.includes(label as any)) {
|
||||
return (
|
||||
<Chip
|
||||
size="small"
|
||||
color={paletteColor[label as typeof VARIANTS[number]]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Chip size="small" {...props} />;
|
||||
return (
|
||||
<Chip
|
||||
size="small"
|
||||
sx={
|
||||
inVariant && isEqual(props.sx, fallback)
|
||||
? {
|
||||
backgroundColor:
|
||||
paletteColor[label as typeof VARIANTS[number]][mode],
|
||||
}
|
||||
: props.sx
|
||||
}
|
||||
{...newProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { AutoTypings, LocalStorageCache } from "monaco-editor-auto-typings";
|
||||
import Editor, { OnMount } from "@monaco-editor/react";
|
||||
|
||||
const defaultCode = `import React from "react";
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Hello World!</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
const handleEditorMount: OnMount = (monacoEditor, monaco) => {
|
||||
console.log("handleEditorMount");
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
target: monaco.languages.typescript.ScriptTarget.ES2016,
|
||||
allowNonTsExtensions: true,
|
||||
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
|
||||
module: monaco.languages.typescript.ModuleKind.CommonJS,
|
||||
noEmit: true,
|
||||
typeRoots: ["node_modules/@types"],
|
||||
});
|
||||
|
||||
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
});
|
||||
|
||||
const autoTypings = AutoTypings.create(monacoEditor, {
|
||||
sourceCache: new LocalStorageCache(), // Cache loaded sources in localStorage. May be omitted
|
||||
monaco: monaco,
|
||||
onError: (error) => {
|
||||
console.log(error);
|
||||
},
|
||||
onUpdate: (update, textual) => {
|
||||
console.log(textual);
|
||||
},
|
||||
});
|
||||
};
|
||||
export default function Function() {
|
||||
const onChange = (value: string | undefined, ev: any) => {
|
||||
//console.log(value)
|
||||
};
|
||||
return (
|
||||
<Editor
|
||||
height="100vh"
|
||||
theme="vs-dark"
|
||||
defaultPath="app.tsx"
|
||||
defaultLanguage="typescript"
|
||||
defaultValue={defaultCode}
|
||||
onChange={onChange}
|
||||
onMount={handleEditorMount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
//export * from "./Function";
|
||||
export { default } from "./Function";
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
Typography,
|
||||
CardActions,
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import { Go as GoIcon } from "@src/assets/icons";
|
||||
|
||||
import RenderedMarkdown from "@src/components/RenderedMarkdown";
|
||||
import { TableSettings } from "@src/types/table";
|
||||
|
||||
export interface ITableCardProps extends TableSettings {
|
||||
link: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function TableCard({
|
||||
section,
|
||||
name,
|
||||
description,
|
||||
link,
|
||||
actions,
|
||||
}: ITableCardProps) {
|
||||
return (
|
||||
<Card style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<CardActionArea component={Link} to={link}>
|
||||
<CardContent style={{ paddingBottom: 0 }}>
|
||||
<Typography variant="overline" component="p">
|
||||
{section}
|
||||
</Typography>
|
||||
<Typography variant="h6" component="h3" gutterBottom>
|
||||
{name}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
|
||||
<CardContent style={{ flexGrow: 1, paddingTop: 0 }}>
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
sx={{
|
||||
minHeight: (theme) =>
|
||||
(theme.typography.body2.lineHeight as number) * 2 + "em",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
component="div"
|
||||
>
|
||||
{description && (
|
||||
<RenderedMarkdown
|
||||
children={description}
|
||||
//restrictionPreset="singleLine"
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
<CardActions>
|
||||
<Button
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon={<GoIcon />}
|
||||
component={Link}
|
||||
to={link}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
{actions}
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
CardActions,
|
||||
Skeleton,
|
||||
} from "@mui/material";
|
||||
|
||||
export default function TableCardSkeleton() {
|
||||
return (
|
||||
<Card style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<CardContent style={{ flexGrow: 1 }}>
|
||||
<Typography variant="overline">
|
||||
<Skeleton width={80} />
|
||||
</Typography>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<Skeleton width={180} />
|
||||
</Typography>
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
sx={{
|
||||
minHeight: (theme) =>
|
||||
(theme.typography.body2.lineHeight as number) * 2 + "em",
|
||||
}}
|
||||
>
|
||||
<Skeleton width={120} />
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
<CardActions sx={{ mb: 1, mx: 1 }}>
|
||||
<Skeleton
|
||||
width={60}
|
||||
height={20}
|
||||
variant="rectangular"
|
||||
sx={{ borderRadius: 1, mr: "auto" }}
|
||||
/>
|
||||
|
||||
<Skeleton variant="circular" width={24} height={24} />
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { TransitionGroup } from "react-transition-group";
|
||||
|
||||
import { Box, Grid, Collapse } from "@mui/material";
|
||||
|
||||
import SectionHeading from "@src/components/SectionHeading";
|
||||
import TableCard from "./TableCard";
|
||||
import SlideTransition from "@src/components/Modal/SlideTransition";
|
||||
|
||||
import { TableSettings } from "@src/types/table";
|
||||
|
||||
export interface ITableGridProps {
|
||||
sections: Record<string, TableSettings[]>;
|
||||
getLink: (table: TableSettings) => string;
|
||||
getActions?: (table: TableSettings) => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function TableGrid({
|
||||
sections,
|
||||
getLink,
|
||||
getActions,
|
||||
}: ITableGridProps) {
|
||||
return (
|
||||
<TransitionGroup>
|
||||
{Object.entries(sections).map(
|
||||
([sectionName, sectionTables], sectionIndex) => {
|
||||
const tableItems = sectionTables
|
||||
.map((table, tableIndex) => {
|
||||
if (!table) return null;
|
||||
|
||||
return (
|
||||
<SlideTransition
|
||||
key={table.id}
|
||||
appear
|
||||
timeout={(sectionIndex + 1) * 100 + tableIndex * 50}
|
||||
>
|
||||
<Grid item xs={12} sm={6} md={4} lg={3}>
|
||||
<TableCard
|
||||
{...table}
|
||||
link={getLink(table)}
|
||||
actions={getActions ? getActions(table) : null}
|
||||
/>
|
||||
</Grid>
|
||||
</SlideTransition>
|
||||
);
|
||||
})
|
||||
.filter((item) => item !== null);
|
||||
|
||||
if (tableItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Collapse key={sectionName}>
|
||||
<Box component="section" sx={{ mt: 4 }}>
|
||||
<SlideTransition
|
||||
key={"grid-section-" + sectionName}
|
||||
in
|
||||
timeout={(sectionIndex + 1) * 100}
|
||||
>
|
||||
<SectionHeading sx={{ pl: 2, pr: 1.5 }}>
|
||||
{sectionName}
|
||||
</SectionHeading>
|
||||
</SlideTransition>
|
||||
|
||||
<Grid component={TransitionGroup} container spacing={2}>
|
||||
{tableItems}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</TransitionGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./TableGrid";
|
||||
export { default } from "./TableGrid";
|
||||
@@ -1,71 +0,0 @@
|
||||
import { TransitionGroup } from "react-transition-group";
|
||||
|
||||
import { Box, Paper, Collapse, List } from "@mui/material";
|
||||
|
||||
import SectionHeading from "@src/components/SectionHeading";
|
||||
import TableListItem from "./TableListItem";
|
||||
import SlideTransition from "@src/components/Modal/SlideTransition";
|
||||
|
||||
import { TableSettings } from "@src/types/table";
|
||||
|
||||
export interface ITableListProps {
|
||||
sections: Record<string, TableSettings[]>;
|
||||
getLink: (table: TableSettings) => string;
|
||||
getActions?: (table: TableSettings) => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function TableList({
|
||||
sections,
|
||||
getLink,
|
||||
getActions,
|
||||
}: ITableListProps) {
|
||||
return (
|
||||
<TransitionGroup>
|
||||
{Object.entries(sections).map(
|
||||
([sectionName, sectionTables], sectionIndex) => {
|
||||
const tableItems = sectionTables
|
||||
.map((table) => {
|
||||
if (!table) return null;
|
||||
|
||||
return (
|
||||
<Collapse key={table.id}>
|
||||
<TableListItem
|
||||
{...table}
|
||||
link={getLink(table)}
|
||||
actions={getActions ? getActions(table) : null}
|
||||
/>
|
||||
</Collapse>
|
||||
);
|
||||
})
|
||||
.filter((item) => item !== null);
|
||||
|
||||
if (tableItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Collapse key={sectionName}>
|
||||
<Box component="section" sx={{ mt: 4 }}>
|
||||
<SlideTransition
|
||||
key={"list-section-" + sectionName}
|
||||
in
|
||||
timeout={(sectionIndex + 1) * 100}
|
||||
>
|
||||
<SectionHeading sx={{ pl: 2, pr: 1 }}>
|
||||
{sectionName}
|
||||
</SectionHeading>
|
||||
</SlideTransition>
|
||||
|
||||
<SlideTransition in timeout={(sectionIndex + 1) * 100}>
|
||||
<Paper>
|
||||
<List disablePadding>
|
||||
<TransitionGroup>{tableItems}</TransitionGroup>
|
||||
</List>
|
||||
</Paper>
|
||||
</SlideTransition>
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</TransitionGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import {
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
Typography,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import GoIcon from "@mui/icons-material/ArrowForward";
|
||||
|
||||
import RenderedMarkdown from "@src/components/RenderedMarkdown";
|
||||
import { TableSettings } from "@src/types/table";
|
||||
|
||||
export interface ITableListItemProps extends TableSettings {
|
||||
link: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function TableListItem({
|
||||
// section,
|
||||
name,
|
||||
description,
|
||||
link,
|
||||
actions,
|
||||
}: ITableListItemProps) {
|
||||
return (
|
||||
<ListItem disableGutters disablePadding>
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
to={link}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
height: 48,
|
||||
py: 0,
|
||||
pr: 0,
|
||||
borderRadius: 2,
|
||||
"& > *": { lineHeight: "48px !important" },
|
||||
flexWrap: "nowrap",
|
||||
overflow: "hidden",
|
||||
|
||||
flexBasis: 160 + 16,
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
mr: 2,
|
||||
}}
|
||||
>
|
||||
<Typography component="h3" variant="button" noWrap>
|
||||
{name}
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
component="div"
|
||||
noWrap
|
||||
sx={{ flexGrow: 1, "& *": { display: "inline" } }}
|
||||
>
|
||||
{description && (
|
||||
<RenderedMarkdown
|
||||
children={description}
|
||||
restrictionPreset="singleLine"
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
{actions}
|
||||
|
||||
<IconButton
|
||||
size="large"
|
||||
color="primary"
|
||||
component={Link}
|
||||
to={link}
|
||||
sx={{ display: { xs: "none", sm: "inline-flex" } }}
|
||||
>
|
||||
<GoIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ListItem, Skeleton } from "@mui/material";
|
||||
|
||||
export default function TableListItemSkeleton() {
|
||||
return (
|
||||
<ListItem disableGutters disablePadding style={{ height: 48 }}>
|
||||
<Skeleton width={160} sx={{ mx: 2, flexShrink: 0 }} />
|
||||
<Skeleton sx={{ mr: 2, flexBasis: 240, flexShrink: 1 }} />
|
||||
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
width={24}
|
||||
height={24}
|
||||
sx={{ ml: "auto", mr: 3, flexShrink: 0 }}
|
||||
/>
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
width={24}
|
||||
height={24}
|
||||
sx={{ mr: 1.5, flexShrink: 0 }}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./TableList";
|
||||
export { default } from "./TableList";
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Zoom, Stack, Typography } from "@mui/material";
|
||||
|
||||
export default function HomeWelcomePrompt() {
|
||||
return (
|
||||
<Zoom in style={{ transformOrigin: `${320 - 52}px ${320 - 52}px` }}>
|
||||
<Stack
|
||||
justifyContent="center"
|
||||
sx={{
|
||||
bgcolor: "primary.main",
|
||||
color: "primary.contrastText",
|
||||
boxShadow: 24,
|
||||
|
||||
width: 320,
|
||||
height: 320,
|
||||
p: 5,
|
||||
borderRadius: "50% 50% 0 50%",
|
||||
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" component="h1" gutterBottom>
|
||||
Get started
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h5" component="p">
|
||||
Create a function
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Zoom>
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,9 @@ export default function GetStartedChecklist({
|
||||
marginRight: `max(env(safe-area-inset-right), 8px)`,
|
||||
width: 360,
|
||||
},
|
||||
".MuiStepLabel-iconContainer.Mui-active svg": {
|
||||
transform: "rotate(0deg) !important",
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
282
src/components/ProjectSettingsDialog/ProjectSettingsDialog.tsx
Normal file
282
src/components/ProjectSettingsDialog/ProjectSettingsDialog.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import React from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
projectScope,
|
||||
projectSettingsDialogAtom,
|
||||
ProjectSettingsDialogTab,
|
||||
rowyRunAtom,
|
||||
secretNamesAtom,
|
||||
updateSecretNamesAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import Modal from "@src/components/Modal";
|
||||
import { Box, Button, Paper, Tab, Tooltip, Typography } from "@mui/material";
|
||||
import { TabContext, TabPanel, TabList } from "@mui/lab";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import SecretDetailsModal from "./SecretDetailsModal";
|
||||
import { runRoutes } from "@src/constants/runRoutes";
|
||||
|
||||
export default function ProjectSettingsDialog() {
|
||||
const [{ open, tab }, setProjectSettingsDialog] = useAtom(
|
||||
projectSettingsDialogAtom,
|
||||
projectScope
|
||||
);
|
||||
const [secretNames] = useAtom(secretNamesAtom, projectScope);
|
||||
const [secretDetailsModal, setSecretDetailsModal] = React.useState<{
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
mode?: "add" | "edit" | "delete";
|
||||
secretName?: string;
|
||||
error?: string;
|
||||
}>({
|
||||
open: false,
|
||||
});
|
||||
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
|
||||
const [updateSecretNames] = useAtom(updateSecretNamesAtom, projectScope);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
setProjectSettingsDialog({ open: false });
|
||||
};
|
||||
|
||||
const handleTabChange = (
|
||||
event: React.SyntheticEvent,
|
||||
newTab: ProjectSettingsDialogTab
|
||||
) => {
|
||||
setProjectSettingsDialog({ tab: newTab });
|
||||
};
|
||||
|
||||
console.log(secretDetailsModal);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
open={open}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
title={"Project settings"}
|
||||
sx={{
|
||||
".MuiDialogContent-root": {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
},
|
||||
}}
|
||||
children={
|
||||
<>
|
||||
<TabContext value={tab}>
|
||||
<Box
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<TabList value={tab} onChange={handleTabChange}>
|
||||
<Tab label="Secret keys" value={"secrets"} />
|
||||
</TabList>
|
||||
</Box>
|
||||
<TabPanel
|
||||
value={tab}
|
||||
sx={{
|
||||
overflowY: "scroll",
|
||||
}}
|
||||
>
|
||||
<Paper elevation={1} variant={"outlined"}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
|
||||
Secrets
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setSecretDetailsModal({
|
||||
open: true,
|
||||
mode: "add",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add secret key
|
||||
</Button>
|
||||
</Box>
|
||||
{secretNames.secretNames?.map((secretName) => (
|
||||
<Box
|
||||
key={secretName}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 3,
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{secretName}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Tooltip title={"Edit"}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
style={{
|
||||
minWidth: "40px",
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
marginRight: "8px",
|
||||
}}
|
||||
onClick={() => {
|
||||
setSecretDetailsModal({
|
||||
open: true,
|
||||
mode: "edit",
|
||||
secretName,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<EditIcon color={"secondary"} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={"Delete"}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
style={{
|
||||
minWidth: "40px",
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}}
|
||||
onClick={() => {
|
||||
console.log("setting", {
|
||||
open: true,
|
||||
mode: "delete",
|
||||
secretName,
|
||||
});
|
||||
setSecretDetailsModal({
|
||||
open: true,
|
||||
mode: "delete",
|
||||
secretName,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon color={"secondary"} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
</TabPanel>
|
||||
</TabContext>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SecretDetailsModal
|
||||
open={secretDetailsModal.open}
|
||||
mode={secretDetailsModal.mode}
|
||||
error={secretDetailsModal.error}
|
||||
loading={secretDetailsModal.loading}
|
||||
secretName={secretDetailsModal.secretName}
|
||||
handleClose={() => {
|
||||
setSecretDetailsModal({ ...secretDetailsModal, open: false });
|
||||
}}
|
||||
handleAdd={async (newSecretName, secretValue) => {
|
||||
setSecretDetailsModal({
|
||||
...secretDetailsModal,
|
||||
loading: true,
|
||||
});
|
||||
try {
|
||||
await rowyRun({
|
||||
route: runRoutes.addSecret,
|
||||
body: {
|
||||
name: newSecretName,
|
||||
value: secretValue,
|
||||
},
|
||||
});
|
||||
setSecretDetailsModal({
|
||||
...secretDetailsModal,
|
||||
open: false,
|
||||
loading: false,
|
||||
});
|
||||
// update secret name causes an unknown modal-related bug, to be fixed
|
||||
// updateSecretNames?.();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
setSecretDetailsModal({
|
||||
...secretDetailsModal,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}}
|
||||
handleEdit={async (secretName, secretValue) => {
|
||||
setSecretDetailsModal({
|
||||
...secretDetailsModal,
|
||||
loading: true,
|
||||
});
|
||||
try {
|
||||
await rowyRun({
|
||||
route: runRoutes.editSecret,
|
||||
body: {
|
||||
name: secretName,
|
||||
value: secretValue,
|
||||
},
|
||||
});
|
||||
setSecretDetailsModal({
|
||||
...secretDetailsModal,
|
||||
open: false,
|
||||
loading: false,
|
||||
});
|
||||
// update secret name causes an unknown modal-related bug, to be fixed
|
||||
// updateSecretNames?.();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
setSecretDetailsModal({
|
||||
...secretDetailsModal,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}}
|
||||
handleDelete={async (secretName) => {
|
||||
setSecretDetailsModal({
|
||||
...secretDetailsModal,
|
||||
loading: true,
|
||||
});
|
||||
try {
|
||||
await rowyRun({
|
||||
route: runRoutes.deleteSecret,
|
||||
body: {
|
||||
name: secretName,
|
||||
},
|
||||
});
|
||||
console.log("Setting", {
|
||||
...secretDetailsModal,
|
||||
open: false,
|
||||
loading: false,
|
||||
});
|
||||
setSecretDetailsModal({
|
||||
...secretDetailsModal,
|
||||
open: false,
|
||||
loading: false,
|
||||
});
|
||||
// update secret name causes an unknown modal-related bug, to be fixed
|
||||
// updateSecretNames?.();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
setSecretDetailsModal({
|
||||
...secretDetailsModal,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
157
src/components/ProjectSettingsDialog/SecretDetailsModal.tsx
Normal file
157
src/components/ProjectSettingsDialog/SecretDetailsModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useState } from "react";
|
||||
import Modal from "@src/components/Modal";
|
||||
import { Box, Button, TextField, Typography } from "@mui/material";
|
||||
import { capitalize } from "lodash-es";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
|
||||
export interface ISecretDetailsModalProps {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
mode?: "add" | "edit" | "delete";
|
||||
error?: string;
|
||||
secretName?: string;
|
||||
handleClose: () => void;
|
||||
handleAdd: (secretName: string, secretValue: string) => void;
|
||||
handleEdit: (secretName: string, secretValue: string) => void;
|
||||
handleDelete: (secretName: string) => void;
|
||||
}
|
||||
|
||||
export default function SecretDetailsModal({
|
||||
open,
|
||||
loading,
|
||||
mode,
|
||||
error,
|
||||
secretName,
|
||||
handleClose,
|
||||
handleAdd,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
}: ISecretDetailsModalProps) {
|
||||
const [newSecretName, setNewSecretName] = useState("");
|
||||
const [secretValue, setSecretValue] = useState("");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
open={open}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
title={`${capitalize(mode)} secret key`}
|
||||
sx={{
|
||||
".MuiDialogContent-root": {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
},
|
||||
}}
|
||||
children={
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{mode === "add" && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-start",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2">Secret Name</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newSecretName}
|
||||
onChange={(e) => setNewSecretName(e.target.value)}
|
||||
/>
|
||||
<Typography
|
||||
variant={"body2"}
|
||||
color={"text.secondary"}
|
||||
fontSize={"12px"}
|
||||
>
|
||||
This will create a secret key on Google Cloud.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{mode === "delete" ? (
|
||||
<Typography>
|
||||
Are you sure you want to delete this secret key {secretName}?
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-start",
|
||||
gap: 1,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2">Secret Value</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={secretValue}
|
||||
onChange={(e) => setSecretValue(e.target.value)}
|
||||
/>
|
||||
<Typography
|
||||
variant={"body2"}
|
||||
color={"text.secondary"}
|
||||
fontSize={"12px"}
|
||||
>
|
||||
Paste your secret key here.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{error?.length && (
|
||||
<Typography color={"error"} marginTop={2}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
gap: 1,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleClose}
|
||||
sx={{ textTransform: "none" }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="contained"
|
||||
color={"primary"}
|
||||
loading={loading}
|
||||
disabled={
|
||||
(mode === "add" && (!newSecretName || !secretValue)) ||
|
||||
(mode === "edit" && !secretValue)
|
||||
}
|
||||
onClick={() => {
|
||||
switch (mode) {
|
||||
case "add":
|
||||
handleAdd(newSecretName, secretValue);
|
||||
break;
|
||||
case "edit":
|
||||
handleEdit(secretName ?? "", secretValue);
|
||||
break;
|
||||
case "delete":
|
||||
handleDelete(secretName ?? "");
|
||||
break;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mode === "delete" ? "Delete" : "Save"}
|
||||
</LoadingButton>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
2
src/components/ProjectSettingsDialog/index.ts
Normal file
2
src/components/ProjectSettingsDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./ProjectSettingsDialog";
|
||||
export { default } from "./ProjectSettingsDialog";
|
||||
@@ -11,14 +11,14 @@ import "tinymce/themes/silver";
|
||||
import "tinymce/icons/default";
|
||||
// Editor styles
|
||||
/* eslint import/no-webpack-loader-syntax: off */
|
||||
import skinCss from "!!raw-loader!tinymce/skins/ui/oxide/skin.min.css";
|
||||
import skinDarkCss from "!!raw-loader!tinymce/skins/ui/oxide-dark/skin.min.css";
|
||||
import skinCss from "tinymce/skins/ui/oxide/skin.min.css?inline";
|
||||
import skinDarkCss from "tinymce/skins/ui/oxide-dark/skin.min.css?inline";
|
||||
// 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";
|
||||
import contentCss from "tinymce/skins/content/default/content.min.css?inline";
|
||||
import contentUiCss from "tinymce/skins/ui/oxide/content.min.css?inline";
|
||||
import contentCssDark from "tinymce/skins/content/dark/content.min.css?inline";
|
||||
import contentUiCssDark from "tinymce/skins/ui/oxide-dark/content.min.css?inline";
|
||||
|
||||
// Plugins
|
||||
import "tinymce/plugins/autoresize";
|
||||
@@ -29,6 +29,14 @@ import "tinymce/plugins/paste";
|
||||
import "tinymce/plugins/help";
|
||||
import "tinymce/plugins/code";
|
||||
import "tinymce/plugins/fullscreen";
|
||||
import { useAtom } from "jotai";
|
||||
import { firebaseStorageAtom } from "@src/sources/ProjectSourceFirebase";
|
||||
import { projectScope } from "@src/atoms/projectScope";
|
||||
import { getDownloadURL, ref, uploadBytesResumable } from "firebase/storage";
|
||||
import { generateId } from "@src/utils/table";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { ColumnConfig, TableRowRef } from "@src/types/table";
|
||||
import { IMAGE_MIME_TYPES } from "./fields/Image";
|
||||
|
||||
const Styles = styled("div", {
|
||||
shouldForwardProp: (prop) => prop !== "focus",
|
||||
@@ -136,6 +144,8 @@ export interface IRichTextEditorProps {
|
||||
id: string;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
column: ColumnConfig;
|
||||
_rowy_ref: TableRowRef;
|
||||
}
|
||||
|
||||
export default function RichTextEditor({
|
||||
@@ -145,10 +155,43 @@ export default function RichTextEditor({
|
||||
id,
|
||||
onFocus,
|
||||
onBlur,
|
||||
column,
|
||||
_rowy_ref,
|
||||
}: IRichTextEditorProps) {
|
||||
const theme = useTheme();
|
||||
const [focus, setFocus] = useState(false);
|
||||
|
||||
const [firebaseStorage] = useAtom(firebaseStorageAtom, projectScope);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const handleImageUpload = (file: any) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const path = _rowy_ref.path;
|
||||
const key = column.key;
|
||||
const storageRef = ref(
|
||||
firebaseStorage,
|
||||
`${path}/${key}/${generateId()}-${file.name}`
|
||||
);
|
||||
const uploadTask = uploadBytesResumable(storageRef, file, {
|
||||
cacheControl: "public, max-age=31536000",
|
||||
});
|
||||
uploadTask.on(
|
||||
"state_changed",
|
||||
null,
|
||||
(error: any) => {
|
||||
reject(error);
|
||||
},
|
||||
() => {
|
||||
getDownloadURL(uploadTask.snapshot.ref).then(
|
||||
(downloadURL: string) => {
|
||||
resolve(downloadURL);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Styles focus={focus} disabled={disabled}>
|
||||
<style>{theme.palette.mode === "dark" ? skinDarkCss : skinCss}</style>
|
||||
@@ -257,8 +300,37 @@ export default function RichTextEditor({
|
||||
],
|
||||
statusbar: false,
|
||||
toolbar:
|
||||
"formatselect | bold italic forecolor | link | fullscreen | bullist numlist outdent indent | removeformat code | help",
|
||||
"formatselect | bold italic forecolor | link | fullscreen | bullist numlist outdent indent | image | removeformat code | help",
|
||||
body_id: id,
|
||||
file_picker_types: "image",
|
||||
file_picker_callback: async (callback, value, meta) => {
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("accept", IMAGE_MIME_TYPES.join(","));
|
||||
|
||||
// Handle file selection
|
||||
input.onchange = async () => {
|
||||
const file = input && input.files && input.files[0];
|
||||
try {
|
||||
const imageUrl = await handleImageUpload(file); // Upload the image to Firebase Storage
|
||||
|
||||
// Create the image object to be inserted into the editor
|
||||
const imageObj = {
|
||||
src: imageUrl,
|
||||
alt: file && file.name,
|
||||
};
|
||||
|
||||
// Pass the image object to the callback function
|
||||
callback(imageUrl, imageObj);
|
||||
} catch (error) {
|
||||
enqueueSnackbar("Error uploading image", {
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
}}
|
||||
value={value}
|
||||
onEditorChange={onChange}
|
||||
|
||||
109
src/components/SelectColors/CustomizeColorModal.tsx
Normal file
109
src/components/SelectColors/CustomizeColorModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { Chip, Typography } from "@mui/material";
|
||||
import Modal from "@src/components/Modal";
|
||||
import ColorPickerInput from "@src/components/ColorPickerInput";
|
||||
import { toColor } from "react-color-palette";
|
||||
import { SelectColorThemeOptions } from ".";
|
||||
|
||||
interface CustomizeColor {
|
||||
currentColor: SelectColorThemeOptions;
|
||||
onChange: (value: SelectColorThemeOptions) => void;
|
||||
}
|
||||
|
||||
const CustomizeColorModal: FC<CustomizeColor> = ({
|
||||
currentColor,
|
||||
onChange,
|
||||
}) => {
|
||||
const [color, setColor] = useState<SelectColorThemeOptions>(currentColor);
|
||||
|
||||
/* Update color value onFocus */
|
||||
useEffect(() => {
|
||||
setColor(currentColor);
|
||||
}, [currentColor]);
|
||||
|
||||
/* Pass value to the onChange function */
|
||||
const handleChange = (color: SelectColorThemeOptions) => {
|
||||
setColor(color);
|
||||
onChange(color);
|
||||
};
|
||||
|
||||
/* MUI Specific state */
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
/* MUI Menu event handlers */
|
||||
const handleClick = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button size="small" color="success" variant="text" onClick={handleClick}>
|
||||
Customise
|
||||
</Button>
|
||||
<Modal
|
||||
title="Customize Color"
|
||||
aria-labelledby="custom-color-picker-modal"
|
||||
aria-describedby="custom-color-picker-modal"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
disableBackdropClick
|
||||
>
|
||||
<Box display="grid" gridTemplateColumns="repeat(6, 1fr)" gap={1}>
|
||||
{/* Light Theme Customize Color */}
|
||||
<Box gridColumn="span 3">
|
||||
<ColorPickerInput
|
||||
value={toColor("hex", color.light)}
|
||||
onChangeComplete={(value) =>
|
||||
handleChange({ ...color, ...{ light: value.hex } })
|
||||
}
|
||||
/>
|
||||
<Grid container gap={1} py={1} px={2} alignItems="center">
|
||||
<Grid item>
|
||||
<Typography fontSize={13} fontWeight="light">
|
||||
Light Theme
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Chip
|
||||
component="small"
|
||||
size="small"
|
||||
label="Option 1"
|
||||
sx={{ backgroundColor: color.light, color: "black" }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Dark Theme Customize Color */}
|
||||
<Box gridColumn="span 3">
|
||||
<ColorPickerInput
|
||||
value={toColor("hex", color.dark)}
|
||||
onChangeComplete={(value) =>
|
||||
handleChange({ ...color, ...{ dark: value.hex } })
|
||||
}
|
||||
/>
|
||||
<Grid container gap={1} py={1} px={2} alignItems="center">
|
||||
<Grid item>
|
||||
<Typography fontSize={13} fontWeight="light">
|
||||
Dark Theme
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Chip
|
||||
component="small"
|
||||
size="small"
|
||||
label="Option 1"
|
||||
sx={{ backgroundColor: color.dark, color: "white" }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomizeColorModal;
|
||||
192
src/components/SelectColors/index.tsx
Normal file
192
src/components/SelectColors/index.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { FC, useState } from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
import Box from "@mui/material/Box";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { Chip, Divider, Typography, useTheme } from "@mui/material";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import FormatColorResetIcon from "@mui/icons-material/FormatColorReset";
|
||||
import { paletteToMui, palette } from "@src/theme/palette";
|
||||
import CustomizeColorModal from "./CustomizeColorModal";
|
||||
|
||||
export interface SelectColorThemeOptions {
|
||||
light: string;
|
||||
dark: string;
|
||||
}
|
||||
|
||||
interface IColorSelect {
|
||||
handleChange: (value: SelectColorThemeOptions) => void;
|
||||
initialValue: SelectColorThemeOptions;
|
||||
}
|
||||
|
||||
const ColorSelect: FC<IColorSelect> = ({ handleChange, initialValue }) => {
|
||||
/* Get current theme */
|
||||
const theme = useTheme();
|
||||
const mode = theme.palette.mode;
|
||||
|
||||
/* Palette - reset paletter to object */
|
||||
const palettes = Object({
|
||||
gray: palette.aGray,
|
||||
blue: palette.blue,
|
||||
red: palette.aRed,
|
||||
green: palette.green,
|
||||
yellow: palette.yellow,
|
||||
pink: palette.pink,
|
||||
teal: palette.teal,
|
||||
tangerine: palette.tangerine,
|
||||
orange: palette.orange,
|
||||
cyan: palette.cyan,
|
||||
amber: palette.amber,
|
||||
lightGreen: palette.lightGreen,
|
||||
lightBlue: palette.lightBlue,
|
||||
purple: palette.purple,
|
||||
});
|
||||
|
||||
/* Hold the current state of a given option defaults to `gray` from the color palette */
|
||||
const [color, setColor] = useState<SelectColorThemeOptions>(
|
||||
initialValue || paletteToMui(palette["gray"])
|
||||
);
|
||||
|
||||
const onChange = (color: SelectColorThemeOptions) => {
|
||||
setColor(color);
|
||||
handleChange(color);
|
||||
};
|
||||
|
||||
/* MUI Specific state for color context menu */
|
||||
const [colorSelectAnchor, setColorSelectAnchor] =
|
||||
useState<null | HTMLElement>(null);
|
||||
const open = Boolean(colorSelectAnchor);
|
||||
|
||||
/* MUI Menu event handlers for color context menu */
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setColorSelectAnchor(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setColorSelectAnchor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
sx={{ margin: "7.5px 0", width: "auto" }}
|
||||
size="small"
|
||||
id="color-picker-btn"
|
||||
aria-controls={open ? "color-picker-menu" : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? "true" : undefined}
|
||||
variant="outlined"
|
||||
disableElevation
|
||||
onClick={handleClick}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
<Box
|
||||
m={0.5}
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 100,
|
||||
backgroundColor: color[mode],
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* Menu */}
|
||||
<Menu
|
||||
sx={{ marginTop: 0.5 }}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
id="color-picker-menu"
|
||||
MenuListProps={{
|
||||
"aria-labelledby": "color-pick-btn",
|
||||
}}
|
||||
anchorEl={colorSelectAnchor}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<Typography
|
||||
fontSize={11}
|
||||
color="secondary"
|
||||
py={1}
|
||||
px={2}
|
||||
fontWeight="bold"
|
||||
>
|
||||
COLOURS
|
||||
</Typography>
|
||||
|
||||
<Grid
|
||||
container
|
||||
py={1}
|
||||
px={2}
|
||||
rowGap={2}
|
||||
columnGap={2}
|
||||
display="grid"
|
||||
gridTemplateColumns="repeat(7, auto)"
|
||||
>
|
||||
{Object.keys(palettes).map((key: string, index: number) => (
|
||||
<Grid item xs sx={{ maxWidth: "fit-content" }}>
|
||||
<Button
|
||||
sx={{
|
||||
minWidth: "25px",
|
||||
minHeight: "25px",
|
||||
backgroundColor: paletteToMui(palettes[key])[mode],
|
||||
borderRadius: 100,
|
||||
"&:hover": {
|
||||
backgroundColor: paletteToMui(palettes[key])[mode],
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
onClick={() => onChange(paletteToMui(palettes[key]))}
|
||||
key={index}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Box pt={1} px={2}>
|
||||
<CustomizeColorModal
|
||||
currentColor={color}
|
||||
onChange={(color) => onChange(color)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box px={2} py={2}>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ borderRadius: 100 }}
|
||||
fullWidth
|
||||
startIcon={<FormatColorResetIcon />}
|
||||
onClick={() => onChange(paletteToMui(palettes["gray"]))}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Grid container gap={1} py={1} px={2} alignItems="center">
|
||||
<Grid item>
|
||||
<Typography fontSize={13} fontWeight="light">
|
||||
Preview
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Chip
|
||||
component="small"
|
||||
size="small"
|
||||
label="Option 1"
|
||||
sx={{ backgroundColor: color[mode] }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorSelect;
|
||||
@@ -46,15 +46,18 @@ export default function UserItem({
|
||||
|
||||
const [value, setValue] = useState(Array.isArray(rolesProp) ? rolesProp : []);
|
||||
const allRoles = new Set(["ADMIN", ...(projectRoles ?? []), ...value]);
|
||||
const hasRowyRun = !!projectSettings.rowyRunUrl;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasRowyRun) {
|
||||
openRowyRunModal({ feature: "User Management" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!user) throw new Error("User is not defined");
|
||||
if (JSON.stringify(value) === JSON.stringify(rolesProp)) return;
|
||||
|
||||
const loadingSnackbarId = enqueueSnackbar("Setting roles…");
|
||||
|
||||
const res = await rowyRun?.({
|
||||
const res = await rowyRun({
|
||||
route: runRoutes.setUserRoles,
|
||||
body: { email: user!.email, roles: value },
|
||||
});
|
||||
@@ -91,7 +94,7 @@ export default function UserItem({
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!projectSettings.rowyRunUrl) {
|
||||
if (!hasRowyRun) {
|
||||
openRowyRunModal({ feature: "User Management" });
|
||||
return;
|
||||
}
|
||||
|
||||
103
src/components/Settings/UserSettings/TableSettings.tsx
Normal file
103
src/components/Settings/UserSettings/TableSettings.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { merge } from "lodash-es";
|
||||
import { IUserSettingsChildProps } from "@src/pages/Settings/UserSettingsPage";
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Divider,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
} from "@mui/material";
|
||||
|
||||
export default function TableSettings({
|
||||
settings,
|
||||
updateSettings,
|
||||
}: IUserSettingsChildProps) {
|
||||
return (
|
||||
<>
|
||||
<FormControl sx={{ my: -10 / 10, display: "flex" }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={Boolean(
|
||||
settings.defaultTableSettings?.saveSortsPopupDisabled
|
||||
)}
|
||||
onChange={(e) => {
|
||||
updateSettings({
|
||||
defaultTableSettings: merge(settings.defaultTableSettings, {
|
||||
saveSortsPopupDisabled: e.target.checked,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Disable popup - to save sorting changes to the team"
|
||||
style={{ marginLeft: -11, marginBottom: 13 }}
|
||||
/>
|
||||
<Collapse in={settings.defaultTableSettings?.saveSortsPopupDisabled}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={Boolean(
|
||||
settings.defaultTableSettings?.automaticallyApplySorts
|
||||
)}
|
||||
onChange={(e) => {
|
||||
updateSettings({
|
||||
defaultTableSettings: merge(settings.defaultTableSettings, {
|
||||
automaticallyApplySorts: e.target.checked,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Automatically apply sorting changes to all users"
|
||||
style={{ marginLeft: 20, marginBottom: 10, marginTop: -13 }}
|
||||
/>
|
||||
</Collapse>
|
||||
|
||||
<Divider />
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={Boolean(
|
||||
settings.defaultTableSettings?.saveColumnSizingPopupDisabled
|
||||
)}
|
||||
onChange={(e) => {
|
||||
updateSettings({
|
||||
defaultTableSettings: merge(settings.defaultTableSettings, {
|
||||
saveColumnSizingPopupDisabled: e.target.checked,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Disable popup - to save column width changes to the team"
|
||||
style={{ marginLeft: -11, marginTop: 13 }}
|
||||
/>
|
||||
<Collapse
|
||||
in={settings.defaultTableSettings?.saveColumnSizingPopupDisabled}
|
||||
>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={Boolean(
|
||||
settings.defaultTableSettings?.automaticallyApplyColumnSizing
|
||||
)}
|
||||
onChange={(e) => {
|
||||
updateSettings({
|
||||
defaultTableSettings: merge(settings.defaultTableSettings, {
|
||||
automaticallyApplyColumnSizing: e.target.checked,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Automatically apply column width changes to all users"
|
||||
style={{ marginLeft: 20 }}
|
||||
/>
|
||||
</Collapse>
|
||||
</FormControl>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export interface IFieldWrapperProps {
|
||||
fieldName?: string;
|
||||
label?: React.ReactNode;
|
||||
debugText?: React.ReactNode;
|
||||
debugValue?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
index?: number;
|
||||
@@ -46,6 +47,7 @@ export default function FieldWrapper({
|
||||
fieldName,
|
||||
label,
|
||||
debugText,
|
||||
debugValue,
|
||||
disabled,
|
||||
hidden,
|
||||
index,
|
||||
@@ -100,7 +102,7 @@ export default function FieldWrapper({
|
||||
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
|
||||
<Suspense fallback={<FieldSkeleton />}>
|
||||
{children ??
|
||||
(!debugText && (
|
||||
(!debugValue && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
@@ -112,7 +114,7 @@ export default function FieldWrapper({
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
{debugText && (
|
||||
{debugValue && (
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Typography
|
||||
variant="body2"
|
||||
@@ -131,7 +133,7 @@ export default function FieldWrapper({
|
||||
</Typography>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
copyToClipboard(debugText as string);
|
||||
copyToClipboard(debugValue as string);
|
||||
enqueueSnackbar("Copied!");
|
||||
}}
|
||||
>
|
||||
@@ -139,7 +141,7 @@ export default function FieldWrapper({
|
||||
</IconButton>
|
||||
<IconButton
|
||||
href={`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${(
|
||||
debugText as string
|
||||
debugValue as string
|
||||
).replace(/\//g, "~2F")}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
|
||||
@@ -3,9 +3,10 @@ import useStateRef from "react-usestateref";
|
||||
import { isEqual, isEmpty } from "lodash-es";
|
||||
|
||||
import FieldWrapper from "./FieldWrapper";
|
||||
import { IFieldConfig } from "@src/components/fields/types";
|
||||
import { FieldType, IFieldConfig } from "@src/components/fields/types";
|
||||
import { getFieldProp } from "@src/components/fields";
|
||||
import { ColumnConfig, TableRowRef } from "@src/types/table";
|
||||
import { TableRow } from "@src/types/table";
|
||||
|
||||
export interface IMemoizedFieldProps {
|
||||
field: ColumnConfig;
|
||||
@@ -16,6 +17,7 @@ export interface IMemoizedFieldProps {
|
||||
isDirty: boolean;
|
||||
onDirty: (fieldName: string) => void;
|
||||
onSubmit: (fieldName: string, value: any) => void;
|
||||
row: TableRow;
|
||||
}
|
||||
|
||||
export const MemoizedField = memo(
|
||||
@@ -28,6 +30,7 @@ export const MemoizedField = memo(
|
||||
isDirty,
|
||||
onDirty,
|
||||
onSubmit,
|
||||
row,
|
||||
...props
|
||||
}: IMemoizedFieldProps) {
|
||||
const [localValue, setLocalValue, localValueRef] = useStateRef(value);
|
||||
@@ -40,9 +43,8 @@ export const MemoizedField = memo(
|
||||
onSubmit(field.fieldName, localValueRef.current);
|
||||
}, [field.fieldName, localValueRef, onSubmit]);
|
||||
|
||||
// Derivative/aggregate field support
|
||||
let type = field.type;
|
||||
if (field.config && field.config.renderFieldType) {
|
||||
if (field.type !== FieldType.formula && field.config?.renderFieldType) {
|
||||
type = field.config.renderFieldType;
|
||||
}
|
||||
|
||||
@@ -78,6 +80,7 @@ export const MemoizedField = memo(
|
||||
},
|
||||
onSubmit: handleSubmit,
|
||||
disabled,
|
||||
row,
|
||||
})}
|
||||
</FieldWrapper>
|
||||
);
|
||||
|
||||
@@ -30,11 +30,21 @@ export default function SideDrawer() {
|
||||
|
||||
const [cell, setCell] = useAtom(selectedCellAtom, tableScope);
|
||||
const [open, setOpen] = useAtom(sideDrawerOpenAtom, tableScope);
|
||||
const selectedRow = find(tableRows, ["_rowy_ref.path", cell?.path]);
|
||||
const selectedCellRowIndex = findIndex(tableRows, [
|
||||
"_rowy_ref.path",
|
||||
cell?.path,
|
||||
]);
|
||||
const selectedRow = find(
|
||||
tableRows,
|
||||
cell?.arrayIndex === undefined
|
||||
? ["_rowy_ref.path", cell?.path]
|
||||
: // if the table is an array table, we need to use the array index to find the row
|
||||
["_rowy_ref.arrayTableData.index", cell?.arrayIndex]
|
||||
);
|
||||
|
||||
const selectedCellRowIndex = findIndex(
|
||||
tableRows,
|
||||
cell?.arrayIndex === undefined
|
||||
? ["_rowy_ref.path", cell?.path]
|
||||
: // if the table is an array table, we need to use the array index to find the row
|
||||
["_rowy_ref.arrayTableData.index", cell?.arrayIndex]
|
||||
);
|
||||
|
||||
const handleNavigate = (direction: "up" | "down") => () => {
|
||||
if (!tableRows || !cell) return;
|
||||
@@ -45,8 +55,9 @@ export default function SideDrawer() {
|
||||
|
||||
setCell((cell) => ({
|
||||
columnKey: cell!.columnKey,
|
||||
path: newPath,
|
||||
path: cell?.arrayIndex !== undefined ? cell.path : newPath,
|
||||
focusInside: false,
|
||||
arrayIndex: cell?.arrayIndex !== undefined ? rowIndex : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -66,7 +66,16 @@ export default function SideDrawerFields({ row }: ISideDrawerFieldsProps) {
|
||||
|
||||
setSaveState("saving");
|
||||
try {
|
||||
await updateField({ path: selectedCell!.path, fieldName, value });
|
||||
await updateField({
|
||||
path: selectedCell!.path,
|
||||
fieldName,
|
||||
value,
|
||||
deleteField: undefined,
|
||||
arrayTableData: {
|
||||
index: selectedCell.arrayIndex,
|
||||
},
|
||||
});
|
||||
|
||||
setSaveState("saved");
|
||||
} catch (e) {
|
||||
enqueueSnackbar((e as Error).message, { variant: "error" });
|
||||
@@ -121,6 +130,7 @@ export default function SideDrawerFields({ row }: ISideDrawerFieldsProps) {
|
||||
onDirty={onDirty}
|
||||
onSubmit={onSubmit}
|
||||
isDirty={dirtyField === field.key}
|
||||
row={row}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -128,7 +138,17 @@ export default function SideDrawerFields({ row }: ISideDrawerFieldsProps) {
|
||||
type="debug"
|
||||
fieldName="_rowy_ref.path"
|
||||
label="Document path"
|
||||
debugText={row._rowy_ref.path ?? row._rowy_ref.id ?? "No ref"}
|
||||
debugText={
|
||||
row._rowy_ref.arrayTableData
|
||||
? row._rowy_ref.path +
|
||||
" → " +
|
||||
row._rowy_ref.arrayTableData.parentField +
|
||||
"[" +
|
||||
row._rowy_ref.arrayTableData.index +
|
||||
"]"
|
||||
: row._rowy_ref.path
|
||||
}
|
||||
debugValue={row._rowy_ref.path ?? row._rowy_ref.id ?? "No ref"}
|
||||
/>
|
||||
|
||||
{userDocHiddenFields.length > 0 && (
|
||||
|
||||
@@ -38,14 +38,12 @@ export const ColumnHeaderSort = memo(function ColumnHeaderSort({
|
||||
const triggerSaveTableSorts = useSaveTableSorts(canEditColumns);
|
||||
|
||||
const handleSortClick = () => {
|
||||
if (nextSort === "none") setTableSorts([]);
|
||||
else setTableSorts([{ key: sortKey, direction: nextSort }]);
|
||||
triggerSaveTableSorts([
|
||||
{
|
||||
key: sortKey,
|
||||
direction: nextSort === "none" ? "asc" : nextSort,
|
||||
},
|
||||
]);
|
||||
setTableSorts(
|
||||
nextSort === "none" ? [] : [{ key: sortKey, direction: nextSort }]
|
||||
);
|
||||
triggerSaveTableSorts(
|
||||
nextSort === "none" ? [] : [{ key: sortKey, direction: nextSort }]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { SnackbarKey, useSnackbar } from "notistack";
|
||||
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
@@ -11,17 +11,25 @@ import {
|
||||
tableScope,
|
||||
updateTableSchemaAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
import { projectScope, updateUserSettingsAtom } from "@src/atoms/projectScope";
|
||||
import {
|
||||
defaultTableSettingsAtom,
|
||||
projectScope,
|
||||
updateUserSettingsAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import { TableSort } from "@src/types/table";
|
||||
|
||||
function useSaveTableSorts(canEditColumns: boolean) {
|
||||
const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope);
|
||||
const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope);
|
||||
const [tableId] = useAtom(tableIdAtom, tableScope);
|
||||
const defaultTableSettings = useAtomValue(
|
||||
defaultTableSettingsAtom,
|
||||
projectScope
|
||||
);
|
||||
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
|
||||
const [snackbarId, setSnackbarId] = useState<SnackbarKey | null>(null);
|
||||
|
||||
// Offer to save when table sorts changes
|
||||
// Offer to save when table sorts changes, depending on user settings
|
||||
const trigger = useCallback(
|
||||
(sorts: TableSort[]) => {
|
||||
if (!updateTableSchema) throw new Error("Cannot update table schema");
|
||||
@@ -33,6 +41,15 @@ function useSaveTableSorts(canEditColumns: boolean) {
|
||||
});
|
||||
}
|
||||
if (!canEditColumns) return;
|
||||
// If the user has disabled the popup, return early
|
||||
if (defaultTableSettings?.saveSortsPopupDisabled) {
|
||||
// If the user has `automaticallyApplySorts` set to true, apply the sorting before returning
|
||||
if (defaultTableSettings?.automaticallyApplySorts) {
|
||||
const updateTable = async () => await updateTableSchema({ sorts });
|
||||
updateTable();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (snackbarId) {
|
||||
closeSnackbar(snackbarId);
|
||||
}
|
||||
@@ -43,7 +60,7 @@ function useSaveTableSorts(canEditColumns: boolean) {
|
||||
updateTable={async () => await updateTableSchema({ sorts })}
|
||||
/>
|
||||
),
|
||||
anchorOrigin: { horizontal: "center", vertical: "top" },
|
||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
||||
})
|
||||
);
|
||||
|
||||
@@ -57,6 +74,7 @@ function useSaveTableSorts(canEditColumns: boolean) {
|
||||
tableId,
|
||||
closeSnackbar,
|
||||
updateTableSchema,
|
||||
defaultTableSettings,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -3,12 +3,19 @@ import { Copy as CopyCells } from "@src/assets/icons";
|
||||
import Paste from "@mui/icons-material/ContentPaste";
|
||||
import { IFieldConfig } from "@src/components/fields/types";
|
||||
import { useMenuAction } from "@src/components/Table/useMenuAction";
|
||||
import { useAtom } from "jotai";
|
||||
import { tableSchemaAtom, tableScope } from "@src/atoms/tableScope";
|
||||
|
||||
import { SUPPORTED_TYPES_PASTE } from "@src/components/Table/useMenuAction";
|
||||
|
||||
// TODO: Remove this and add `handlePaste` function to column config
|
||||
export const BasicContextMenuActions: IFieldConfig["contextMenuActions"] = (
|
||||
selectedCell,
|
||||
reset
|
||||
) => {
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
const selectedCol = tableSchema.columns?.[selectedCell.columnKey];
|
||||
|
||||
const handleClose = async () => await reset?.();
|
||||
const { handleCopy, handlePaste, cellValue } = useMenuAction(
|
||||
selectedCell,
|
||||
@@ -24,9 +31,17 @@ export const BasicContextMenuActions: IFieldConfig["contextMenuActions"] = (
|
||||
disabled:
|
||||
cellValue === undefined || cellValue === null || cellValue === "",
|
||||
},
|
||||
{ label: "Paste", icon: <Paste />, onClick: handlePaste },
|
||||
];
|
||||
|
||||
if (SUPPORTED_TYPES_PASTE.has(selectedCol?.type)) {
|
||||
contextMenuActions.push({
|
||||
label: "Paste",
|
||||
icon: <Paste />,
|
||||
onClick: handlePaste,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
return contextMenuActions;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
projectIdAtom,
|
||||
userRolesAtom,
|
||||
altPressAtom,
|
||||
tableAddRowIdTypeAtom,
|
||||
confirmDialogAtom,
|
||||
updateUserSettingsAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import {
|
||||
tableScope,
|
||||
@@ -34,8 +34,12 @@ import {
|
||||
deleteRowAtom,
|
||||
updateFieldAtom,
|
||||
tableFiltersPopoverAtom,
|
||||
_updateRowDbAtom,
|
||||
tableIdAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
import { TableRow } from "@src/types/table";
|
||||
import { generateId } from "@src/utils/table";
|
||||
|
||||
interface IMenuContentsProps {
|
||||
onClose: () => void;
|
||||
@@ -45,7 +49,6 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
|
||||
const [projectId] = useAtom(projectIdAtom, projectScope);
|
||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
||||
const [altPress] = useAtom(altPressAtom, projectScope);
|
||||
const [addRowIdType] = useAtom(tableAddRowIdTypeAtom, projectScope);
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
@@ -54,27 +57,124 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
|
||||
const addRow = useSetAtom(addRowAtom, tableScope);
|
||||
const deleteRow = useSetAtom(deleteRowAtom, tableScope);
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
const [updateRowDb] = useAtom(_updateRowDbAtom, tableScope);
|
||||
const openTableFiltersPopover = useSetAtom(
|
||||
tableFiltersPopoverAtom,
|
||||
tableScope
|
||||
);
|
||||
const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope);
|
||||
const [tableId] = useAtom(tableIdAtom, tableScope);
|
||||
|
||||
const addRowIdType = tableSchema.idType || "decrement";
|
||||
|
||||
if (!tableSchema.columns || !selectedCell) return null;
|
||||
|
||||
const selectedColumn = tableSchema.columns[selectedCell.columnKey];
|
||||
const row = find(tableRows, ["_rowy_ref.path", selectedCell.path]);
|
||||
const row = find(
|
||||
tableRows,
|
||||
selectedCell?.arrayIndex === undefined
|
||||
? ["_rowy_ref.path", selectedCell.path]
|
||||
: // if the table is an array table, we need to use the array index to find the row
|
||||
["_rowy_ref.arrayTableData.index", selectedCell.arrayIndex]
|
||||
);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const actionGroups: IContextMenuItem[][] = [];
|
||||
|
||||
const handleDuplicate = () => {
|
||||
addRow({
|
||||
row,
|
||||
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
|
||||
});
|
||||
const _duplicate = () => {
|
||||
if (row._rowy_ref.arrayTableData !== undefined) {
|
||||
if (!updateRowDb) return;
|
||||
|
||||
return updateRowDb("", {}, undefined, {
|
||||
index: row._rowy_ref.arrayTableData.index,
|
||||
operation: {
|
||||
addRow: "bottom",
|
||||
base: row,
|
||||
},
|
||||
});
|
||||
}
|
||||
return addRow({
|
||||
row: row,
|
||||
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
|
||||
});
|
||||
};
|
||||
|
||||
if (altPress || row._rowy_ref.arrayTableData !== undefined) {
|
||||
_duplicate();
|
||||
} else {
|
||||
confirm({
|
||||
title: "Duplicate row?",
|
||||
body: (
|
||||
<>
|
||||
Row path:
|
||||
<br />
|
||||
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
|
||||
{row._rowy_ref.path}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
confirm: "Duplicate",
|
||||
handleConfirm: _duplicate,
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleDelete = () => deleteRow(row._rowy_ref.path);
|
||||
const handleDelete = () => {
|
||||
const _delete = () =>
|
||||
deleteRow({
|
||||
path: row._rowy_ref.path,
|
||||
options: row._rowy_ref.arrayTableData,
|
||||
});
|
||||
|
||||
if (altPress || row._rowy_ref.arrayTableData !== undefined) {
|
||||
_delete();
|
||||
} else {
|
||||
confirm({
|
||||
title: "Delete row?",
|
||||
body: (
|
||||
<>
|
||||
Row path:
|
||||
<br />
|
||||
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
|
||||
{row._rowy_ref.path}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: _delete,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearValue = () => {
|
||||
const clearValue = () => {
|
||||
updateField({
|
||||
path: selectedCell.path,
|
||||
fieldName: selectedColumn.fieldName,
|
||||
arrayTableData: {
|
||||
index: selectedCell.arrayIndex,
|
||||
},
|
||||
value: null,
|
||||
deleteField: true,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (altPress || row._rowy_ref.arrayTableData !== undefined) {
|
||||
clearValue();
|
||||
} else {
|
||||
confirm({
|
||||
title: "Clear cell value?",
|
||||
body: "The cell’s value cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: clearValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const rowActions: IContextMenuItem[] = [
|
||||
{
|
||||
label: "Copy ID",
|
||||
@@ -112,51 +212,14 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
|
||||
disabled:
|
||||
tableSettings.tableType === "collectionGroup" ||
|
||||
(!userRoles.includes("ADMIN") && tableSettings.readOnly),
|
||||
onClick: altPress
|
||||
? handleDuplicate
|
||||
: () => {
|
||||
confirm({
|
||||
title: "Duplicate row?",
|
||||
body: (
|
||||
<>
|
||||
Row path:
|
||||
<br />
|
||||
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
|
||||
{row._rowy_ref.path}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
confirm: "Duplicate",
|
||||
handleConfirm: handleDuplicate,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onClick: handleDuplicate,
|
||||
},
|
||||
{
|
||||
label: altPress ? "Delete" : "Delete…",
|
||||
color: "error",
|
||||
icon: <DeleteIcon />,
|
||||
disabled: !userRoles.includes("ADMIN") && tableSettings.readOnly,
|
||||
onClick: altPress
|
||||
? handleDelete
|
||||
: () => {
|
||||
confirm({
|
||||
title: "Delete row?",
|
||||
body: (
|
||||
<>
|
||||
Row path:
|
||||
<br />
|
||||
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
|
||||
{row._rowy_ref.path}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: handleDelete,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onClick: handleDelete,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -184,28 +247,48 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
|
||||
|
||||
// Cell actions
|
||||
// TODO: Add copy and paste here
|
||||
const cellValue = row?.[selectedCell.columnKey];
|
||||
const handleClearValue = () =>
|
||||
updateField({
|
||||
path: selectedCell.path,
|
||||
fieldName: selectedColumn.fieldName,
|
||||
value: null,
|
||||
deleteField: true,
|
||||
});
|
||||
|
||||
const selectedColumnKey = selectedCell.columnKey;
|
||||
const selectedColumnKeySplit = selectedColumnKey.split(".");
|
||||
|
||||
const getNestedFieldValue = (object: TableRow, keys: string[]) => {
|
||||
let value = object;
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
|
||||
if (value && typeof value === "object" && key in value) {
|
||||
value = value[key];
|
||||
} else {
|
||||
// Handle cases where the key does not exist in the nested structure
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const cellValue = getNestedFieldValue(row, selectedColumnKeySplit);
|
||||
|
||||
const columnFilters = getFieldProp(
|
||||
"filter",
|
||||
selectedColumn?.type === FieldType.derivative
|
||||
? selectedColumn.config?.renderFieldType
|
||||
: selectedColumn?.type
|
||||
);
|
||||
const handleFilterValue = () => {
|
||||
openTableFiltersPopover({
|
||||
defaultQuery: {
|
||||
const handleFilterBy = () => {
|
||||
const filters = [
|
||||
{
|
||||
key: selectedColumn.fieldName,
|
||||
operator: columnFilters!.operators[0]?.value || "==",
|
||||
value: cellValue,
|
||||
id: generateId(),
|
||||
},
|
||||
});
|
||||
];
|
||||
|
||||
if (updateUserSettings) {
|
||||
updateUserSettings({ tables: { [`${tableId}`]: { filters } } });
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
const cellActions = [
|
||||
@@ -218,24 +301,13 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
|
||||
!row ||
|
||||
cellValue === undefined ||
|
||||
getFieldProp("group", selectedColumn?.type) === "Auditing",
|
||||
onClick: altPress
|
||||
? handleClearValue
|
||||
: () => {
|
||||
confirm({
|
||||
title: "Clear cell value?",
|
||||
body: "The cell’s value cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: handleClearValue,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onClick: handleClearValue,
|
||||
},
|
||||
{
|
||||
label: "Filter value",
|
||||
label: "Filter by",
|
||||
icon: <FilterIcon />,
|
||||
disabled: !columnFilters || cellValue === undefined,
|
||||
onClick: handleFilterValue,
|
||||
onClick: handleFilterBy,
|
||||
},
|
||||
];
|
||||
actionGroups.push(cellActions);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { Offline, Online } from "react-detect-offline";
|
||||
|
||||
import { Grid, Stack, Typography, Button, Divider } from "@mui/material";
|
||||
import {
|
||||
@@ -42,9 +41,15 @@ export default function EmptyTable() {
|
||||
Get started
|
||||
</Typography>
|
||||
<Typography>
|
||||
There is existing data in the Firestore collection:
|
||||
{tableSettings.isCollection === false
|
||||
? "There is existing data in the Array Sub Table:"
|
||||
: "There is existing data in the Firestore collection:"}
|
||||
<br />
|
||||
<code>{tableSettings.collection}</code>
|
||||
<code>
|
||||
{tableSettings.collection}
|
||||
{tableSettings.subTableKey?.length &&
|
||||
`.${tableSettings.subTableKey}`}
|
||||
</code>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
@@ -72,47 +77,56 @@ export default function EmptyTable() {
|
||||
Get started
|
||||
</Typography>
|
||||
<Typography>
|
||||
There is no data in the Firestore collection:
|
||||
{tableSettings.isCollection === false
|
||||
? "There is no data in this Array Sub Table:"
|
||||
: "There is no data in the Firestore collection:"}
|
||||
<br />
|
||||
<code>{tableSettings.collection}</code>
|
||||
<code>
|
||||
{tableSettings.collection}
|
||||
{tableSettings.subTableKey?.length &&
|
||||
`.${tableSettings.subTableKey}`}
|
||||
</code>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs>
|
||||
<Typography paragraph>
|
||||
You can import data from an external source:
|
||||
</Typography>
|
||||
{tableSettings.isCollection !== false && (
|
||||
<>
|
||||
<Grid item xs>
|
||||
<Typography paragraph>
|
||||
You can import data from an external source:
|
||||
</Typography>
|
||||
|
||||
<ImportData
|
||||
render={(onClick) => (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<ImportIcon />}
|
||||
onClick={onClick}
|
||||
>
|
||||
Import data
|
||||
</Button>
|
||||
)}
|
||||
PopoverProps={{
|
||||
anchorOrigin: {
|
||||
vertical: "bottom",
|
||||
horizontal: "center",
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: "top",
|
||||
horizontal: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<ImportData
|
||||
render={(onClick) => (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<ImportIcon />}
|
||||
onClick={onClick}
|
||||
>
|
||||
Import data
|
||||
</Button>
|
||||
)}
|
||||
PopoverProps={{
|
||||
anchorOrigin: {
|
||||
vertical: "bottom",
|
||||
horizontal: "center",
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: "top",
|
||||
horizontal: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item>
|
||||
<Divider orientation="vertical">
|
||||
<Typography variant="overline">or</Typography>
|
||||
</Divider>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Divider orientation="vertical">
|
||||
<Typography variant="overline">or</Typography>
|
||||
</Divider>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Grid item xs>
|
||||
<Typography paragraph>
|
||||
@@ -133,36 +147,34 @@ export default function EmptyTable() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Offline>
|
||||
<EmptyState
|
||||
role="alert"
|
||||
Icon={OfflineIcon}
|
||||
message="You’re offline"
|
||||
description="Go online to view this table’s data"
|
||||
style={{ height: `calc(100vh - ${TOP_BAR_HEIGHT}px)` }}
|
||||
/>
|
||||
</Offline>
|
||||
|
||||
<Online>
|
||||
<Stack
|
||||
spacing={3}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
height: `calc(100vh - ${TOP_BAR_HEIGHT}px)`,
|
||||
width: "100%",
|
||||
p: 2,
|
||||
maxWidth: 480,
|
||||
margin: "0 auto",
|
||||
textAlign: "center",
|
||||
}}
|
||||
id="empty-table"
|
||||
>
|
||||
{contents}
|
||||
</Stack>
|
||||
</Online>
|
||||
</>
|
||||
);
|
||||
if (navigator.onLine) {
|
||||
return (
|
||||
<Stack
|
||||
spacing={3}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
height: `calc(100vh - ${TOP_BAR_HEIGHT}px)`,
|
||||
width: "100%",
|
||||
p: 2,
|
||||
maxWidth: 480,
|
||||
margin: "0 auto",
|
||||
textAlign: "center",
|
||||
}}
|
||||
id="empty-table"
|
||||
>
|
||||
{contents}
|
||||
</Stack>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<EmptyState
|
||||
role="alert"
|
||||
Icon={OfflineIcon}
|
||||
message="You’re offline"
|
||||
description="Go online to view this table’s data"
|
||||
style={{ height: `calc(100vh - ${TOP_BAR_HEIGHT}px)` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import MenuIcon from "@mui/icons-material/MoreHoriz";
|
||||
import {
|
||||
projectScope,
|
||||
userRolesAtom,
|
||||
tableAddRowIdTypeAtom,
|
||||
altPressAtom,
|
||||
confirmDialogAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
@@ -20,28 +19,90 @@ import {
|
||||
addRowAtom,
|
||||
deleteRowAtom,
|
||||
contextMenuTargetAtom,
|
||||
_updateRowDbAtom,
|
||||
tableSchemaAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
|
||||
export const FinalColumn = memo(function FinalColumn({
|
||||
row,
|
||||
focusInsideCell,
|
||||
}: IRenderedTableCellProps) {
|
||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
||||
const [addRowIdType] = useAtom(tableAddRowIdTypeAtom, projectScope);
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
const [updateRowDb] = useAtom(_updateRowDbAtom, tableScope);
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
|
||||
const addRow = useSetAtom(addRowAtom, tableScope);
|
||||
const deleteRow = useSetAtom(deleteRowAtom, tableScope);
|
||||
const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope);
|
||||
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
const [altPress] = useAtom(altPressAtom, projectScope);
|
||||
const handleDelete = () => deleteRow(row.original._rowy_ref.path);
|
||||
|
||||
const handleDelete = () => {
|
||||
const _delete = () =>
|
||||
deleteRow({
|
||||
path: row.original._rowy_ref.path,
|
||||
options: row.original._rowy_ref.arrayTableData,
|
||||
});
|
||||
if (altPress || row.original._rowy_ref.arrayTableData !== undefined) {
|
||||
_delete();
|
||||
} else {
|
||||
confirm({
|
||||
title: "Delete row?",
|
||||
body: (
|
||||
<>
|
||||
Row path:
|
||||
<br />
|
||||
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
|
||||
{row.original._rowy_ref.path}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: _delete,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addRowIdType = tableSchema.idType || "decrement";
|
||||
|
||||
const handleDuplicate = () => {
|
||||
addRow({
|
||||
row: row.original,
|
||||
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
|
||||
});
|
||||
const _duplicate = () => {
|
||||
if (row.original._rowy_ref.arrayTableData !== undefined) {
|
||||
if (!updateRowDb) return;
|
||||
|
||||
return updateRowDb("", {}, undefined, {
|
||||
index: row.original._rowy_ref.arrayTableData.index,
|
||||
operation: {
|
||||
addRow: "bottom",
|
||||
base: row.original,
|
||||
},
|
||||
});
|
||||
}
|
||||
return addRow({
|
||||
row: row.original,
|
||||
setId: addRowIdType === "custom" ? "decrement" : addRowIdType,
|
||||
});
|
||||
};
|
||||
if (altPress || row.original._rowy_ref.arrayTableData !== undefined) {
|
||||
_duplicate();
|
||||
} else {
|
||||
confirm({
|
||||
title: "Duplicate row?",
|
||||
body: (
|
||||
<>
|
||||
Row path:
|
||||
<br />
|
||||
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
|
||||
{row.original._rowy_ref.path}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
confirm: "Duplicate",
|
||||
handleConfirm: _duplicate,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!userRoles.includes("ADMIN") && tableSettings.readOnly === true)
|
||||
@@ -73,28 +134,7 @@ export const FinalColumn = memo(function FinalColumn({
|
||||
size="small"
|
||||
color="inherit"
|
||||
disabled={tableSettings.tableType === "collectionGroup"}
|
||||
onClick={
|
||||
altPress
|
||||
? handleDuplicate
|
||||
: () => {
|
||||
confirm({
|
||||
title: "Duplicate row?",
|
||||
body: (
|
||||
<>
|
||||
Row path:
|
||||
<br />
|
||||
<code
|
||||
style={{ userSelect: "all", wordBreak: "break-all" }}
|
||||
>
|
||||
{row.original._rowy_ref.path}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
confirm: "Duplicate",
|
||||
handleConfirm: handleDuplicate,
|
||||
});
|
||||
}
|
||||
}
|
||||
onClick={handleDuplicate}
|
||||
className="row-hover-iconButton"
|
||||
tabIndex={focusInsideCell ? 0 : -1}
|
||||
>
|
||||
@@ -106,29 +146,7 @@ export const FinalColumn = memo(function FinalColumn({
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
onClick={
|
||||
altPress
|
||||
? handleDelete
|
||||
: () => {
|
||||
confirm({
|
||||
title: "Delete row?",
|
||||
body: (
|
||||
<>
|
||||
Row path:
|
||||
<br />
|
||||
<code
|
||||
style={{ userSelect: "all", wordBreak: "break-all" }}
|
||||
>
|
||||
{row.original._rowy_ref.path}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: handleDelete,
|
||||
});
|
||||
}
|
||||
}
|
||||
onClick={handleDelete}
|
||||
className="row-hover-iconButton"
|
||||
tabIndex={focusInsideCell ? 0 : -1}
|
||||
sx={{
|
||||
|
||||
@@ -7,6 +7,7 @@ import EmptyState from "@src/components/EmptyState";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
import { getFieldProp } from "@src/components/fields";
|
||||
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
|
||||
import useConverter from "@src/components/TableModals/ImportCsvWizard/useConverter";
|
||||
|
||||
export interface ICellProps
|
||||
extends Partial<
|
||||
@@ -25,12 +26,14 @@ export interface ICellProps
|
||||
export default function Cell({
|
||||
field,
|
||||
type,
|
||||
value,
|
||||
value: value_,
|
||||
name,
|
||||
rowHeight = DEFAULT_ROW_HEIGHT,
|
||||
...props
|
||||
}: ICellProps) {
|
||||
const tableCell = type ? getFieldProp("TableCell", type) : null;
|
||||
const { checkAndConvert } = useConverter();
|
||||
const value = checkAndConvert(value_, type);
|
||||
|
||||
return (
|
||||
<StyledTable>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMemo, useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useThrottledCallback } from "use-debounce";
|
||||
import {
|
||||
RowSelectionState,
|
||||
createColumnHelper,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
@@ -25,6 +26,7 @@ import EmptyState from "@src/components/EmptyState";
|
||||
import {
|
||||
tableScope,
|
||||
tableSchemaAtom,
|
||||
reactTableAtom,
|
||||
tableColumnsOrderedAtom,
|
||||
tableRowsAtom,
|
||||
tableNextPageAtom,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
selectedCellAtom,
|
||||
tableSortsAtom,
|
||||
tableIdAtom,
|
||||
serverDocCountAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
import { projectScope, userSettingsAtom } from "@src/atoms/projectScope";
|
||||
import { getFieldType, getFieldProp } from "@src/components/fields";
|
||||
@@ -42,6 +45,7 @@ import { useSaveColumnSizing } from "./useSaveColumnSizing";
|
||||
import useHotKeys from "./useHotKey";
|
||||
import type { TableRow, ColumnConfig } from "@src/types/table";
|
||||
import useStateWithRef from "./useStateWithRef"; // testing with useStateWithRef
|
||||
import { Checkbox, FormControlLabel } from "@mui/material";
|
||||
|
||||
export const DEFAULT_ROW_HEIGHT = 41;
|
||||
export const DEFAULT_COL_WIDTH = 150;
|
||||
@@ -75,6 +79,20 @@ export interface ITableProps {
|
||||
* Loading state handled by Suspense in parent component.
|
||||
*/
|
||||
emptyState?: React.ReactNode;
|
||||
/**
|
||||
* If defined, it will show a checkbox to select rows. The
|
||||
* state is to be maintained by the parent component.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
* const selectedRowsProp = useMemo(() => ({state: selectedRows, setState: setSelectedRows}), [selectedRows, setSelectedRows])
|
||||
* <Table selectedRows={selectedRowsProp} />
|
||||
*/
|
||||
selectedRows?: {
|
||||
state: RowSelectionState;
|
||||
setState: React.Dispatch<React.SetStateAction<{}>>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,12 +111,15 @@ export default function Table({
|
||||
canEditCells,
|
||||
hiddenColumns,
|
||||
emptyState,
|
||||
selectedRows,
|
||||
}: ITableProps) {
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
const [serverDocCount] = useAtom(serverDocCountAtom, tableScope);
|
||||
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
|
||||
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
|
||||
const [tablePage, setTablePage] = useAtom(tablePageAtom, tableScope);
|
||||
const setReactTable = useSetAtom(reactTableAtom, tableScope);
|
||||
|
||||
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
|
||||
|
||||
@@ -143,7 +164,57 @@ export default function Table({
|
||||
}
|
||||
|
||||
return _columns;
|
||||
}, [tableColumnsOrdered, canAddColumns, canEditCells]);
|
||||
}, [tableColumnsOrdered, canAddColumns, canEditCells, selectedRows]);
|
||||
|
||||
columns.unshift(
|
||||
...useMemo(() => {
|
||||
if (!selectedRows) return [];
|
||||
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: "_rowy_select",
|
||||
size: 41.8, // TODO: We shouldn't have to change this often
|
||||
header: ({ table }) => {
|
||||
const checked =
|
||||
Object.keys(selectedRows.state).length >= serverDocCount!;
|
||||
const indeterminate = Object.keys(selectedRows.state).length > 0;
|
||||
return (
|
||||
<FormControlLabel
|
||||
sx={{ margin: 0 }}
|
||||
label=""
|
||||
control={
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
indeterminate={indeterminate && !checked}
|
||||
onChange={() => {
|
||||
table.toggleAllRowsSelected(
|
||||
!table.getIsAllRowsSelected()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<FormControlLabel
|
||||
label=""
|
||||
sx={{ margin: 0 }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [selectedRows])
|
||||
);
|
||||
|
||||
// Get user’s hidden columns from props and memoize into a `VisibilityState`
|
||||
const columnVisibility: VisibilityState = useMemo(() => {
|
||||
@@ -151,14 +222,16 @@ export default function Table({
|
||||
return hiddenColumns.reduce((a, c) => ({ ...a, [c]: false }), {});
|
||||
}, [hiddenColumns]);
|
||||
|
||||
// Get frozen columns and memoize into a `ColumnPinningState`
|
||||
const columnPinning: ColumnPinningState = useMemo(
|
||||
() => ({
|
||||
left: columns
|
||||
.filter(
|
||||
(c) => c.meta?.fixed && c.id && columnVisibility[c.id] !== false
|
||||
)
|
||||
.map((c) => c.id!),
|
||||
left: [
|
||||
...(selectedRows ? ["_rowy_select"] : []),
|
||||
...columns
|
||||
.filter(
|
||||
(c) => c.meta?.fixed && c.id && columnVisibility[c.id] !== false
|
||||
)
|
||||
.map((c) => c.id!),
|
||||
],
|
||||
}),
|
||||
[columns, columnVisibility]
|
||||
);
|
||||
@@ -172,6 +245,14 @@ export default function Table({
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId,
|
||||
columnResizeMode: "onChange",
|
||||
...(selectedRows && {
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
state: {
|
||||
rowSelection: selectedRows.state,
|
||||
},
|
||||
onRowSelectionChange: selectedRows.setState,
|
||||
}),
|
||||
});
|
||||
|
||||
// Store local `columnSizing` state so we can save it to table schema
|
||||
@@ -185,6 +266,10 @@ export default function Table({
|
||||
state: { ...prev.state, columnVisibility, columnPinning, columnSizing },
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
}));
|
||||
// Update the reactTable atom when table state changes.
|
||||
useMemo(() => {
|
||||
setReactTable(table);
|
||||
}, [table, setReactTable]);
|
||||
// Get rows and columns for virtualization
|
||||
const { rows } = table.getRowModel();
|
||||
const leafColumns = table.getVisibleLeafColumns();
|
||||
@@ -200,7 +285,7 @@ export default function Table({
|
||||
const { handler: hotKeysHandler } = useHotKeys([
|
||||
["mod+C", handleCopy],
|
||||
["mod+X", handleCut],
|
||||
["mod+V", handlePaste],
|
||||
["mod+V", (e) => handlePaste], // So the event isn't passed to the handler
|
||||
]);
|
||||
|
||||
// Handle prompt to save local column sizes if user `canEditColumns`
|
||||
@@ -211,15 +296,15 @@ export default function Table({
|
||||
if (result.destination?.index === undefined || !result.draggableId)
|
||||
return;
|
||||
|
||||
console.log(result.draggableId, result.destination.index);
|
||||
|
||||
updateColumn({
|
||||
key: result.draggableId,
|
||||
index: result.destination.index,
|
||||
index: selectedRows
|
||||
? result.destination.index - 1
|
||||
: result.destination.index,
|
||||
config: {},
|
||||
});
|
||||
},
|
||||
[updateColumn]
|
||||
[updateColumn, selectedRows]
|
||||
);
|
||||
|
||||
const fetchMoreOnBottomReached = useThrottledCallback(
|
||||
@@ -237,12 +322,15 @@ export default function Table({
|
||||
// for large screen heights
|
||||
useEffect(() => {
|
||||
fetchMoreOnBottomReached(containerRef.current);
|
||||
}, [
|
||||
fetchMoreOnBottomReached,
|
||||
tablePage,
|
||||
tableNextPage.loading,
|
||||
containerRef,
|
||||
]);
|
||||
}, [fetchMoreOnBottomReached, tableNextPage.loading, containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("paste", handlePaste);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("paste", handlePaste);
|
||||
};
|
||||
}, [handlePaste]);
|
||||
|
||||
// apply user default sort on first render
|
||||
const [applySort, setApplySort] = useState(true);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { memo } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import type { Column, Row, ColumnSizingState } from "@tanstack/react-table";
|
||||
import {
|
||||
Column,
|
||||
Row,
|
||||
ColumnSizingState,
|
||||
flexRender,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import StyledRow from "./Styled/StyledRow";
|
||||
import OutOfOrderIndicator from "./OutOfOrderIndicator";
|
||||
@@ -18,6 +23,7 @@ import { getFieldProp } from "@src/components/fields";
|
||||
import type { TableRow } from "@src/types/table";
|
||||
import useVirtualization from "./useVirtualization";
|
||||
import { DEFAULT_ROW_HEIGHT, OUT_OF_ORDER_MARGIN } from "./Table";
|
||||
import StyledCell from "./Styled/StyledCell";
|
||||
|
||||
export interface ITableBodyProps {
|
||||
/**
|
||||
@@ -83,7 +89,7 @@ export const TableBody = memo(function TableBody({
|
||||
|
||||
return (
|
||||
<StyledRow
|
||||
key={row.id}
|
||||
key={row.id + row.original._rowy_ref.arrayTableData?.index}
|
||||
role="row"
|
||||
aria-rowindex={row.index + 2}
|
||||
style={{
|
||||
@@ -102,7 +108,10 @@ export const TableBody = memo(function TableBody({
|
||||
|
||||
const isSelectedCell =
|
||||
selectedCell?.path === row.original._rowy_ref.path &&
|
||||
selectedCell?.columnKey === cell.column.id;
|
||||
selectedCell?.columnKey === cell.column.id &&
|
||||
// if the table is an array sub table, we need to check the array index as well
|
||||
selectedCell?.arrayIndex ===
|
||||
row.original._rowy_ref.arrayTableData?.index;
|
||||
|
||||
const fieldTypeGroup = getFieldProp(
|
||||
"group",
|
||||
@@ -111,6 +120,14 @@ export const TableBody = memo(function TableBody({
|
||||
const isReadOnlyCell =
|
||||
fieldTypeGroup === "Auditing" || fieldTypeGroup === "Metadata";
|
||||
|
||||
if (cell.id.includes("_rowy_select")) {
|
||||
return (
|
||||
<StyledCell key={cell.id} role="gridcell">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</StyledCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function EditorCellController({
|
||||
fieldName: props.column.fieldName,
|
||||
value: localValueRef.current,
|
||||
deleteField: localValueRef.current === undefined,
|
||||
arrayTableData: props.row?._rowy_ref.arrayTableData,
|
||||
});
|
||||
} catch (e) {
|
||||
enqueueSnackbar((e as Error).message, { variant: "error" });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { spreadSx } from "@src/utils/ui";
|
||||
|
||||
export interface IEditorCellTextFieldProps extends IEditorCellProps<string> {
|
||||
InputProps?: Partial<InputBaseProps>;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export default function EditorCellTextField({
|
||||
@@ -11,6 +12,7 @@ export default function EditorCellTextField({
|
||||
value,
|
||||
onDirty,
|
||||
onChange,
|
||||
onBlur,
|
||||
setFocusInsideCell,
|
||||
InputProps = {},
|
||||
}: IEditorCellTextFieldProps) {
|
||||
@@ -19,7 +21,12 @@ export default function EditorCellTextField({
|
||||
return (
|
||||
<InputBase
|
||||
value={value}
|
||||
onBlur={() => onDirty()}
|
||||
onBlur={() => {
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
onDirty();
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
fullWidth
|
||||
autoFocus
|
||||
@@ -42,6 +49,11 @@ export default function EditorCellTextField({
|
||||
setTimeout(() => setFocusInsideCell(false));
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
// Trigger an onBlur in case we have any final mutations
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
|
||||
// Removes focus from inside cell, triggering save on unmount
|
||||
setFocusInsideCell(false);
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ export const TableCell = memo(function TableCell({
|
||||
focusInsideCell,
|
||||
setFocusInsideCell: (focusInside: boolean) =>
|
||||
setSelectedCell({
|
||||
arrayIndex: row.original._rowy_ref.arrayTableData?.index,
|
||||
path: row.original._rowy_ref.path,
|
||||
columnKey: cell.column.id,
|
||||
focusInside,
|
||||
@@ -166,6 +167,7 @@ export const TableCell = memo(function TableCell({
|
||||
}}
|
||||
onClick={(e) => {
|
||||
setSelectedCell({
|
||||
arrayIndex: row.original._rowy_ref.arrayTableData?.index,
|
||||
path: row.original._rowy_ref.path,
|
||||
columnKey: cell.column.id,
|
||||
focusInside: false,
|
||||
@@ -174,6 +176,7 @@ export const TableCell = memo(function TableCell({
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
setSelectedCell({
|
||||
arrayIndex: row.original._rowy_ref.arrayTableData?.index,
|
||||
path: row.original._rowy_ref.path,
|
||||
columnKey: cell.column.id,
|
||||
focusInside: true,
|
||||
@@ -182,13 +185,21 @@ export const TableCell = memo(function TableCell({
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedCell({
|
||||
path: row.original._rowy_ref.path,
|
||||
columnKey: cell.column.id,
|
||||
focusInside: false,
|
||||
let isEditorCell = false;
|
||||
|
||||
setSelectedCell((prev) => {
|
||||
isEditorCell = prev?.focusInside === true;
|
||||
return {
|
||||
arrayIndex: row.original._rowy_ref.arrayTableData?.index,
|
||||
path: row.original._rowy_ref.path,
|
||||
columnKey: cell.column.id,
|
||||
focusInside: false,
|
||||
};
|
||||
});
|
||||
(e.target as HTMLDivElement).focus();
|
||||
setContextMenuTarget(e.target as HTMLElement);
|
||||
if (!isEditorCell) {
|
||||
setContextMenuTarget(e.target as HTMLElement);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderedValidationTooltip}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { memo, Fragment } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
|
||||
import type { DropResult } from "react-beautiful-dnd";
|
||||
import type { ColumnSizingState, HeaderGroup } from "@tanstack/react-table";
|
||||
import {
|
||||
ColumnSizingState,
|
||||
HeaderGroup,
|
||||
flexRender,
|
||||
} from "@tanstack/react-table";
|
||||
import type { TableRow } from "@src/types/table";
|
||||
|
||||
import StyledRow from "./Styled/StyledRow";
|
||||
@@ -11,6 +15,7 @@ import FinalColumnHeader from "./FinalColumn/FinalColumnHeader";
|
||||
|
||||
import { tableScope, selectedCellAtom } from "@src/atoms/tableScope";
|
||||
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
|
||||
import StyledColumnHeader from "./Styled/StyledColumnHeader";
|
||||
|
||||
export interface ITableHeaderProps {
|
||||
/** Headers with context from TanStack Table state */
|
||||
@@ -46,8 +51,12 @@ export const TableHeader = memo(function TableHeader({
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleDropColumn}>
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<Droppable droppableId="droppable-column" direction="horizontal">
|
||||
{headerGroups.map((headerGroup, _i) => (
|
||||
<Droppable
|
||||
key={_i}
|
||||
droppableId="droppable-column"
|
||||
direction="horizontal"
|
||||
>
|
||||
{(provided) => (
|
||||
<StyledRow
|
||||
key={headerGroup.id}
|
||||
@@ -65,6 +74,20 @@ export const TableHeader = memo(function TableHeader({
|
||||
|
||||
const isLastHeader = i === headerGroup.headers.length - 1;
|
||||
|
||||
if (header.id === "_rowy_select")
|
||||
return (
|
||||
<StyledColumnHeader
|
||||
key={header.id}
|
||||
role="columnheader"
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</StyledColumnHeader>
|
||||
);
|
||||
|
||||
// Render later, after the drag & drop placeholder
|
||||
if (header.id === "_rowy_column_actions")
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,6 @@ export default function useHotKeys(actions: HotKeysAction[]) {
|
||||
const event_ = "nativeEvent" in event ? event.nativeEvent : event;
|
||||
actions.forEach(([hotkey, handler_]) => {
|
||||
if (getHotkeyMatcher(hotkey)(event_)) {
|
||||
event.preventDefault();
|
||||
handler_(event_);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -128,6 +128,11 @@ export function useKeyboardNavigation({
|
||||
? tableRows[newRowIndex]._rowy_ref.path
|
||||
: "_rowy_header",
|
||||
columnKey: leafColumns[newColIndex].id! || leafColumns[0].id!,
|
||||
arrayIndex:
|
||||
newRowIndex > -1
|
||||
? tableRows[newRowIndex]._rowy_ref.arrayTableData?.index
|
||||
: undefined,
|
||||
|
||||
// When selected cell changes, exit current cell
|
||||
focusInside: false,
|
||||
};
|
||||
|
||||
@@ -15,16 +15,74 @@ import { ColumnConfig } from "@src/types/table";
|
||||
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
|
||||
const SUPPORTED_TYPES = new Set([
|
||||
import { format } from "date-fns";
|
||||
import { DATE_FORMAT, DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||
import { isDate, isFunction } from "lodash-es";
|
||||
import { getDurationString } from "@src/components/fields/Duration/utils";
|
||||
import { doc } from "firebase/firestore";
|
||||
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
|
||||
import { projectScope } from "@src/atoms/projectScope";
|
||||
|
||||
export const SUPPORTED_TYPES_COPY = new Set([
|
||||
// TEXT
|
||||
FieldType.shortText,
|
||||
FieldType.longText,
|
||||
FieldType.number,
|
||||
FieldType.email,
|
||||
FieldType.percentage,
|
||||
FieldType.phone,
|
||||
FieldType.richText,
|
||||
FieldType.email,
|
||||
FieldType.phone,
|
||||
FieldType.url,
|
||||
// SELECT
|
||||
FieldType.singleSelect,
|
||||
FieldType.multiSelect,
|
||||
// NUMERIC
|
||||
FieldType.checkbox,
|
||||
FieldType.number,
|
||||
FieldType.percentage,
|
||||
FieldType.rating,
|
||||
FieldType.slider,
|
||||
FieldType.color,
|
||||
FieldType.geoPoint,
|
||||
// DATE & TIME
|
||||
FieldType.date,
|
||||
FieldType.dateTime,
|
||||
FieldType.duration,
|
||||
// FILE
|
||||
FieldType.image,
|
||||
FieldType.file,
|
||||
// CODE
|
||||
FieldType.json,
|
||||
FieldType.code,
|
||||
FieldType.markdown,
|
||||
FieldType.array,
|
||||
// AUDIT
|
||||
FieldType.createdBy,
|
||||
FieldType.updatedBy,
|
||||
FieldType.createdAt,
|
||||
FieldType.updatedAt,
|
||||
// CONNECTION
|
||||
FieldType.reference,
|
||||
FieldType.id,
|
||||
]);
|
||||
|
||||
export const SUPPORTED_TYPES_PASTE = new Set([
|
||||
// TEXT
|
||||
FieldType.shortText,
|
||||
FieldType.longText,
|
||||
FieldType.richText,
|
||||
FieldType.email,
|
||||
FieldType.phone,
|
||||
FieldType.url,
|
||||
// NUMERIC
|
||||
FieldType.number,
|
||||
FieldType.percentage,
|
||||
FieldType.rating,
|
||||
FieldType.slider,
|
||||
// CODE
|
||||
FieldType.json,
|
||||
FieldType.code,
|
||||
FieldType.markdown,
|
||||
// CONNECTION
|
||||
FieldType.reference,
|
||||
]);
|
||||
|
||||
export function useMenuAction(
|
||||
@@ -35,16 +93,25 @@ export function useMenuAction(
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
const [cellValue, setCellValue] = useState<string | undefined>();
|
||||
const [cellValue, setCellValue] = useState<any>();
|
||||
const [selectedCol, setSelectedCol] = useState<ColumnConfig>();
|
||||
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
if (cellValue !== undefined && cellValue !== null && cellValue !== "") {
|
||||
if (selectedCol?.type === FieldType.id && selectedCell?.path) {
|
||||
await navigator.clipboard.writeText(
|
||||
typeof cellValue === "object" ? JSON.stringify(cellValue) : cellValue
|
||||
selectedCell?.path.split("/").pop() || ""
|
||||
);
|
||||
enqueueSnackbar("Copied");
|
||||
} else if (
|
||||
cellValue !== undefined &&
|
||||
cellValue !== null &&
|
||||
cellValue !== ""
|
||||
) {
|
||||
const value = getValue(cellValue);
|
||||
await navigator.clipboard.writeText(value);
|
||||
enqueueSnackbar("Copied");
|
||||
} else {
|
||||
await navigator.clipboard.writeText("");
|
||||
}
|
||||
@@ -56,21 +123,30 @@ export function useMenuAction(
|
||||
|
||||
const handleCut = useCallback(async () => {
|
||||
try {
|
||||
if (!selectedCell || !selectedCol || !cellValue) return;
|
||||
if (!selectedCell || !selectedCol) return;
|
||||
if (cellValue !== undefined && cellValue !== null && cellValue !== "") {
|
||||
await navigator.clipboard.writeText(
|
||||
typeof cellValue === "object" ? JSON.stringify(cellValue) : cellValue
|
||||
);
|
||||
const value = getValue(cellValue);
|
||||
await navigator.clipboard.writeText(value);
|
||||
enqueueSnackbar("Copied");
|
||||
} else {
|
||||
await navigator.clipboard.writeText("");
|
||||
}
|
||||
if (cellValue !== undefined)
|
||||
if (
|
||||
cellValue !== undefined &&
|
||||
selectedCol.type !== FieldType.createdAt &&
|
||||
selectedCol.type !== FieldType.updatedAt &&
|
||||
selectedCol.type !== FieldType.createdBy &&
|
||||
selectedCol.type !== FieldType.updatedBy &&
|
||||
selectedCol.type !== FieldType.checkbox
|
||||
)
|
||||
updateField({
|
||||
path: selectedCell.path,
|
||||
fieldName: selectedCol.fieldName,
|
||||
value: undefined,
|
||||
deleteField: true,
|
||||
arrayTableData: {
|
||||
index: selectedCell.arrayIndex,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
enqueueSnackbar(`Failed to cut: ${error}`, { variant: "error" });
|
||||
@@ -85,77 +161,227 @@ export function useMenuAction(
|
||||
handleClose,
|
||||
]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
try {
|
||||
if (!selectedCell || !selectedCol) return;
|
||||
let text;
|
||||
const handlePaste = useCallback(
|
||||
async (e?: ClipboardEvent) => {
|
||||
try {
|
||||
text = await navigator.clipboard.readText();
|
||||
} catch (e) {
|
||||
enqueueSnackbar(`Read clilboard permission denied.`, {
|
||||
variant: "error",
|
||||
if (!selectedCell || !selectedCol) return;
|
||||
let text: string;
|
||||
// Firefox doesn't allow for reading clipboard data, hence the workaround
|
||||
if (navigator.userAgent.includes("Firefox")) {
|
||||
if (!e || !e.clipboardData) {
|
||||
enqueueSnackbar(
|
||||
`If you're on Firefox, please use the hotkey instead (Ctrl + V / Cmd + V).`,
|
||||
{
|
||||
variant: "info",
|
||||
autoHideDuration: 7000,
|
||||
}
|
||||
);
|
||||
enqueueSnackbar(`Cannot read clipboard data.`, {
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
text = e.clipboardData.getData("text/plain") || "";
|
||||
} else {
|
||||
try {
|
||||
text = await navigator.clipboard.readText();
|
||||
} catch (e) {
|
||||
enqueueSnackbar(`Read clipboard permission denied.`, {
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const cellDataType = getFieldProp(
|
||||
"dataType",
|
||||
getFieldType(selectedCol)
|
||||
);
|
||||
let parsed;
|
||||
switch (cellDataType) {
|
||||
case "number":
|
||||
parsed = Number(text);
|
||||
if (isNaN(parsed)) throw new Error(`${text} is not a number`);
|
||||
break;
|
||||
case "string":
|
||||
parsed = text;
|
||||
break;
|
||||
case "reference":
|
||||
try {
|
||||
parsed = doc(firebaseDb, text);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(`Invalid reference.`, { variant: "error" });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
parsed = JSON.parse(text);
|
||||
break;
|
||||
}
|
||||
|
||||
if (selectedCol.type === FieldType.slider) {
|
||||
if (parsed < selectedCol.config?.min)
|
||||
parsed = selectedCol.config?.min;
|
||||
else if (parsed > selectedCol.config?.max)
|
||||
parsed = selectedCol.config?.max;
|
||||
}
|
||||
|
||||
if (selectedCol.type === FieldType.rating) {
|
||||
if (parsed < 0) parsed = 0;
|
||||
if (parsed > (selectedCol.config?.max || 5))
|
||||
parsed = selectedCol.config?.max || 5;
|
||||
}
|
||||
|
||||
if (selectedCol.type === FieldType.percentage) {
|
||||
parsed = parsed / 100;
|
||||
}
|
||||
updateField({
|
||||
path: selectedCell.path,
|
||||
fieldName: selectedCol.fieldName,
|
||||
value: parsed,
|
||||
arrayTableData: {
|
||||
index: selectedCell.arrayIndex,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
enqueueSnackbar(
|
||||
`${selectedCol?.type} field does not support the data type being pasted`,
|
||||
{ variant: "error" }
|
||||
);
|
||||
}
|
||||
const cellDataType = getFieldProp("dataType", getFieldType(selectedCol));
|
||||
let parsed;
|
||||
switch (cellDataType) {
|
||||
case "number":
|
||||
parsed = Number(text);
|
||||
if (isNaN(parsed)) throw new Error(`${text} is not a number`);
|
||||
break;
|
||||
case "string":
|
||||
parsed = text;
|
||||
break;
|
||||
default:
|
||||
parsed = JSON.parse(text);
|
||||
break;
|
||||
}
|
||||
updateField({
|
||||
path: selectedCell.path,
|
||||
fieldName: selectedCol.fieldName,
|
||||
value: parsed,
|
||||
});
|
||||
} catch (error) {
|
||||
enqueueSnackbar(
|
||||
`${selectedCol?.type} field does not support the data type being pasted`,
|
||||
{ variant: "error" }
|
||||
);
|
||||
}
|
||||
if (handleClose) handleClose();
|
||||
}, [selectedCell, selectedCol, updateField, enqueueSnackbar, handleClose]);
|
||||
if (handleClose) handleClose();
|
||||
},
|
||||
[selectedCell, selectedCol, updateField, enqueueSnackbar, handleClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCell) return setCellValue("");
|
||||
const selectedCol = tableSchema.columns?.[selectedCell.columnKey];
|
||||
if (!selectedCol) return setCellValue("");
|
||||
setSelectedCol(selectedCol);
|
||||
const selectedRow = find(tableRows, ["_rowy_ref.path", selectedCell.path]);
|
||||
|
||||
const selectedRow = find(
|
||||
tableRows,
|
||||
selectedCell.arrayIndex === undefined
|
||||
? ["_rowy_ref.path", selectedCell.path]
|
||||
: // if the table is an array table, we need to use the array index to find the row
|
||||
["_rowy_ref.arrayTableData.index", selectedCell.arrayIndex]
|
||||
);
|
||||
setCellValue(get(selectedRow, selectedCol.fieldName));
|
||||
}, [selectedCell, tableSchema, tableRows]);
|
||||
|
||||
const checkEnabled = useCallback(
|
||||
const checkEnabledCopy = useCallback(
|
||||
(func: Function) => {
|
||||
if (!selectedCol) {
|
||||
return function () {
|
||||
enqueueSnackbar(`No selected cell`, {
|
||||
variant: "error",
|
||||
});
|
||||
};
|
||||
}
|
||||
const fieldType = getFieldType(selectedCol);
|
||||
return function () {
|
||||
if (SUPPORTED_TYPES.has(selectedCol?.type)) {
|
||||
if (SUPPORTED_TYPES_COPY.has(fieldType)) {
|
||||
return func();
|
||||
} else {
|
||||
enqueueSnackbar(`${fieldType} field cannot be copied`, {
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
[enqueueSnackbar, selectedCol?.type]
|
||||
);
|
||||
|
||||
const checkEnabledPaste = useCallback(
|
||||
(func: Function) => {
|
||||
if (!selectedCol) {
|
||||
return function () {
|
||||
enqueueSnackbar(`No selected cell`, {
|
||||
variant: "error",
|
||||
});
|
||||
};
|
||||
}
|
||||
const fieldType = getFieldType(selectedCol);
|
||||
return function (e?: ClipboardEvent) {
|
||||
if (SUPPORTED_TYPES_PASTE.has(fieldType)) {
|
||||
return func(e);
|
||||
} else {
|
||||
enqueueSnackbar(
|
||||
`${selectedCol?.type} field cannot be copied using keyboard shortcut`,
|
||||
`${fieldType} field does not support paste functionality`,
|
||||
{
|
||||
variant: "info",
|
||||
variant: "error",
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
},
|
||||
[selectedCol]
|
||||
[enqueueSnackbar, selectedCol?.type]
|
||||
);
|
||||
|
||||
const getValue = useCallback(
|
||||
(cellValue: any) => {
|
||||
switch (selectedCol?.type) {
|
||||
case FieldType.percentage:
|
||||
return cellValue * 100;
|
||||
case FieldType.json:
|
||||
case FieldType.color:
|
||||
case FieldType.geoPoint:
|
||||
return JSON.stringify(cellValue);
|
||||
case FieldType.date:
|
||||
if (
|
||||
(!!cellValue && isFunction(cellValue.toDate)) ||
|
||||
isDate(cellValue)
|
||||
) {
|
||||
try {
|
||||
return format(
|
||||
isDate(cellValue) ? cellValue : cellValue.toDate(),
|
||||
selectedCol.config?.format || DATE_FORMAT
|
||||
);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
case FieldType.dateTime:
|
||||
case FieldType.createdAt:
|
||||
case FieldType.updatedAt:
|
||||
if (
|
||||
(!!cellValue && isFunction(cellValue.toDate)) ||
|
||||
isDate(cellValue)
|
||||
) {
|
||||
try {
|
||||
return format(
|
||||
isDate(cellValue) ? cellValue : cellValue.toDate(),
|
||||
selectedCol.config?.format || DATE_TIME_FORMAT
|
||||
);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
case FieldType.duration:
|
||||
return getDurationString(
|
||||
cellValue.start.toDate(),
|
||||
cellValue.end.toDate()
|
||||
);
|
||||
case FieldType.image:
|
||||
case FieldType.file:
|
||||
return cellValue[0].downloadURL;
|
||||
case FieldType.createdBy:
|
||||
case FieldType.updatedBy:
|
||||
return cellValue.displayName;
|
||||
case FieldType.reference:
|
||||
return cellValue.path;
|
||||
default:
|
||||
return cellValue;
|
||||
}
|
||||
},
|
||||
[cellValue, selectedCol]
|
||||
);
|
||||
|
||||
return {
|
||||
handleCopy: checkEnabled(handleCopy),
|
||||
handleCut: checkEnabled(handleCut),
|
||||
handlePaste: handlePaste,
|
||||
handleCopy: checkEnabledCopy(handleCopy),
|
||||
handleCut: checkEnabledCopy(handleCut),
|
||||
handlePaste: checkEnabledPaste(handlePaste),
|
||||
cellValue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { isEqual, isEmpty } from "lodash-es";
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
updateColumnAtom,
|
||||
IUpdateColumnOptions,
|
||||
} from "@src/atoms/tableScope";
|
||||
import {
|
||||
defaultTableSettingsAtom,
|
||||
projectScope,
|
||||
} from "@src/atoms/projectScope";
|
||||
import { DEBOUNCE_DELAY } from "./Table";
|
||||
import { ColumnSizingState } from "@tanstack/react-table";
|
||||
|
||||
@@ -26,14 +30,31 @@ export function useSaveColumnSizing(
|
||||
) {
|
||||
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
|
||||
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
|
||||
const defaultTableSettings = useAtomValue(
|
||||
defaultTableSettingsAtom,
|
||||
projectScope
|
||||
);
|
||||
|
||||
// Debounce for saving to schema
|
||||
const [debouncedColumnSizing] = useDebounce(columnSizing, DEBOUNCE_DELAY, {
|
||||
equalityFn: isEqual,
|
||||
});
|
||||
// Offer to save when column sizing changes
|
||||
// Offer to save when column sizing changes, depending on user settings
|
||||
useEffect(() => {
|
||||
if (!canEditColumns || isEmpty(debouncedColumnSizing)) return;
|
||||
// If the user has disabled the popup, return early
|
||||
if (defaultTableSettings?.saveColumnSizingPopupDisabled) {
|
||||
// If the user has `automaticallyApplyColumnSizing` set to true, apply the column width before returning
|
||||
if (defaultTableSettings?.automaticallyApplyColumnSizing) {
|
||||
const updateTable = async () => {
|
||||
for (const [key, value] of Object.entries(debouncedColumnSizing)) {
|
||||
await updateColumn({ key, config: { width: value } });
|
||||
}
|
||||
};
|
||||
updateTable();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const snackbarId = enqueueSnackbar("Save column sizes for all users?", {
|
||||
action: (
|
||||
@@ -42,7 +63,7 @@ export function useSaveColumnSizing(
|
||||
updateColumn={updateColumn}
|
||||
/>
|
||||
),
|
||||
anchorOrigin: { horizontal: "center", vertical: "top" },
|
||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
||||
});
|
||||
|
||||
return () => closeSnackbar(snackbarId);
|
||||
@@ -52,6 +73,7 @@ export function useSaveColumnSizing(
|
||||
enqueueSnackbar,
|
||||
closeSnackbar,
|
||||
updateColumn,
|
||||
defaultTableSettings,
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -71,7 +71,8 @@ export function useVirtualization(
|
||||
const definedWidth = localWidth || schemaWidth;
|
||||
|
||||
if (definedWidth === undefined) return DEFAULT_COL_WIDTH;
|
||||
if (definedWidth < MIN_COL_WIDTH) return MIN_COL_WIDTH;
|
||||
if (definedWidth < MIN_COL_WIDTH && columnDef.id !== "_rowy_select")
|
||||
return MIN_COL_WIDTH;
|
||||
return definedWidth;
|
||||
},
|
||||
[leafColumns, columnSizing]
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
@@ -98,15 +99,17 @@ export default function Details() {
|
||||
Description
|
||||
</Typography>
|
||||
{isAdmin && (
|
||||
<IconButton
|
||||
aria-label="Edit description"
|
||||
onClick={() => {
|
||||
setEditDescription(!editDescription);
|
||||
}}
|
||||
sx={{ top: 4 }}
|
||||
>
|
||||
{editDescription ? <EditOffIcon /> : <EditIcon />}
|
||||
</IconButton>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
aria-label="Edit description"
|
||||
onClick={() => {
|
||||
setEditDescription(!editDescription);
|
||||
}}
|
||||
sx={{ top: 4 }}
|
||||
>
|
||||
{editDescription ? <EditOffIcon /> : <EditIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
{editDescription ? (
|
||||
@@ -145,15 +148,17 @@ export default function Details() {
|
||||
Details
|
||||
</Typography>
|
||||
{isAdmin && (
|
||||
<IconButton
|
||||
aria-label="Edit details"
|
||||
onClick={() => {
|
||||
setEditDetails(!editDetails);
|
||||
}}
|
||||
sx={{ top: 4 }}
|
||||
>
|
||||
{editDetails ? <EditOffIcon /> : <EditIcon />}
|
||||
</IconButton>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
aria-label="Edit details"
|
||||
onClick={() => {
|
||||
setEditDetails(!editDetails);
|
||||
}}
|
||||
sx={{ top: 4 }}
|
||||
>
|
||||
{editDetails ? <EditOffIcon /> : <EditIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
<Box
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconButton,
|
||||
Stack,
|
||||
styled,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
@@ -111,12 +112,14 @@ export default function SideDrawer() {
|
||||
Information
|
||||
</Typography>
|
||||
</Stack>
|
||||
<IconButton
|
||||
onClick={() => setSideDrawer(RESET)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Tooltip title="Close">
|
||||
<IconButton
|
||||
onClick={() => setSideDrawer(RESET)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -150,6 +150,7 @@ export default function BuildLogsSnack({
|
||||
timeRange: { type: "days", value: 7 },
|
||||
buildLogExpanded: 0,
|
||||
});
|
||||
setExpanded(false);
|
||||
}}
|
||||
style={{ color: "white" }}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { parse as json2csv } from "json2csv";
|
||||
import { Parser } from "@json2csv/plainjs";
|
||||
import { saveAs } from "file-saver";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { getDocs } from "firebase/firestore";
|
||||
@@ -33,6 +33,27 @@ const selectedColumnsJsonReducer =
|
||||
(doc: TableRow) =>
|
||||
(accumulator: Record<string, any>, currentColumn: ColumnConfig) => {
|
||||
const value = get(doc, currentColumn.key);
|
||||
|
||||
if (
|
||||
currentColumn.type === FieldType.file ||
|
||||
currentColumn.type === FieldType.image
|
||||
) {
|
||||
return {
|
||||
...accumulator,
|
||||
[currentColumn.key]: value
|
||||
? value
|
||||
.map((item: { downloadURL: string }) => item.downloadURL)
|
||||
.join()
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (currentColumn.type === FieldType.reference) {
|
||||
return {
|
||||
...accumulator,
|
||||
[currentColumn.key]: value ? value.path : "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...accumulator,
|
||||
[currentColumn.key]: value,
|
||||
@@ -150,10 +171,10 @@ export default function Export({
|
||||
const csvData = docs.map((doc: any) =>
|
||||
columns.reduce(selectedColumnsCsvReducer(doc), {})
|
||||
);
|
||||
const csv = json2csv(
|
||||
csvData,
|
||||
const parser = new Parser(
|
||||
exportType === "tsv" ? { delimiter: "\t" } : undefined
|
||||
);
|
||||
const csv = parser.parse(csvData);
|
||||
const csvBlob = new Blob([csv], {
|
||||
type: `text/${exportType};charset=utf-8`,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const extensionTypes = [
|
||||
"buildshipAuthenticatedTrigger",
|
||||
"task",
|
||||
"docSync",
|
||||
"historySnapshot",
|
||||
@@ -15,6 +16,7 @@ export const extensionTypes = [
|
||||
export type ExtensionType = typeof extensionTypes[number];
|
||||
|
||||
export const extensionNames: Record<ExtensionType, string> = {
|
||||
buildshipAuthenticatedTrigger: "BuildShip Authenticated Trigger",
|
||||
task: "Task",
|
||||
docSync: "Doc Sync",
|
||||
historySnapshot: "History Snapshot",
|
||||
@@ -61,8 +63,32 @@ export interface IRuntimeOptions {
|
||||
export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"];
|
||||
|
||||
const extensionBodyTemplate = {
|
||||
buildshipAuthenticatedTrigger: `const extensionBody: BuildshipAuthenticatedTriggerBody = async({row, db, change, ref, logging}) => {
|
||||
logging.log("extensionBody started")
|
||||
|
||||
// Put your endpoint URL and request body below.
|
||||
// It will trigger your endpoint with the request body.
|
||||
return ({
|
||||
buildshipConfig: {
|
||||
projectId: "",
|
||||
workflowId: ""
|
||||
},
|
||||
body: JSON.stringify({
|
||||
row,
|
||||
ref: {
|
||||
id: ref.id,
|
||||
path: ref.path
|
||||
},
|
||||
change: {
|
||||
before: change.before.get(),
|
||||
after: change.after.get(),
|
||||
},
|
||||
// Add your own payload here
|
||||
})
|
||||
})
|
||||
}`,
|
||||
task: `const extensionBody: TaskBody = async({row, db, change, ref, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("extensionBody started")
|
||||
|
||||
// Import any NPM package needed
|
||||
@@ -90,10 +116,10 @@ const extensionBodyTemplate = {
|
||||
else console.error(result)
|
||||
})
|
||||
*/
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
docSync: `const extensionBody: DocSyncBody = async({row, db, change, ref, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("extensionBody started")
|
||||
|
||||
return ({
|
||||
@@ -101,20 +127,20 @@ const extensionBodyTemplate = {
|
||||
row: row, // object of data to sync, usually the row itself
|
||||
targetPath: "", // fill in the path here
|
||||
})
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
historySnapshot: `const extensionBody: HistorySnapshotBody = async({row, db, change, ref, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("extensionBody started")
|
||||
|
||||
return ({
|
||||
trackedFields: [], // a list of string of column names
|
||||
collectionId: "historySnapshots", // optionally change the sub-collection id of where the history snapshots are stored
|
||||
})
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
algoliaIndex: `const extensionBody: AlgoliaIndexBody = async({row, db, change, ref, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("extensionBody started")
|
||||
|
||||
return ({
|
||||
@@ -123,34 +149,34 @@ const extensionBodyTemplate = {
|
||||
index: "", // algolia index to sync to
|
||||
objectID: ref.id, // algolia object ID, ref.id is one possible choice
|
||||
})
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
meiliIndex: `const extensionBody: MeiliIndexBody = async({row, db, change, ref, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("extensionBody started")
|
||||
|
||||
return({
|
||||
fieldsToSync: [], // a list of string of column names
|
||||
row: row, // object of data to sync, usually the row itself
|
||||
index: "", // algolia index to sync to
|
||||
objectID: ref.id, // algolia object ID, ref.id is one possible choice
|
||||
index: "", // meili search index to sync to
|
||||
objectID: ref.id, // meili search object ID, ref.id is one possible choice
|
||||
})
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
bigqueryIndex: `const extensionBody: BigqueryIndexBody = async({row, db, change, ref, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("extensionBody started")
|
||||
|
||||
return ({
|
||||
fieldsToSync: [], // a list of string of column names
|
||||
row: row, // object of data to sync, usually the row itself
|
||||
index: "", // algolia index to sync to
|
||||
objectID: ref.id, // algolia object ID, ref.id is one possible choice
|
||||
index: "", // bigquery dataset to sync to
|
||||
objectID: ref.id, // bigquery object ID, ref.id is one possible choice
|
||||
})
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
slackMessage: `const extensionBody: SlackMessageBody = async({row, db, change, ref, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("extensionBody started")
|
||||
|
||||
// Import any NPM package needed
|
||||
@@ -162,10 +188,10 @@ const extensionBodyTemplate = {
|
||||
text: "", // the text parameter to pass in to slack api
|
||||
attachments: [], // the attachments parameter to pass in to slack api
|
||||
})
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
sendgridEmail: `const extensionBody: SendgridEmailBody = async({row, db, change, ref, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("extensionBody started")
|
||||
|
||||
// Import any NPM package needed
|
||||
@@ -187,10 +213,10 @@ const extensionBodyTemplate = {
|
||||
// add any other custom args you want to pass to sendgrid events here
|
||||
},
|
||||
})
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("extensionBody started")
|
||||
|
||||
// Import any NPM package needed
|
||||
@@ -202,10 +228,10 @@ const extensionBodyTemplate = {
|
||||
method: "",
|
||||
callback: ()=>{},
|
||||
})
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
twilioMessage: `const extensionBody: TwilioMessageBody = async({row, db, change, ref, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("extensionBody started")
|
||||
|
||||
// Import any NPM package needed
|
||||
@@ -218,10 +244,10 @@ const extensionBodyTemplate = {
|
||||
to: "", // recipient phone number - eg: row.<fieldname>
|
||||
body: "Hi there!" // message text
|
||||
})
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
pushNotification: `const extensionBody: PushNotificationBody = async({row, db, change, ref, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("extensionBody started")
|
||||
|
||||
// Import any NPM package needed
|
||||
@@ -259,7 +285,7 @@ const extensionBodyTemplate = {
|
||||
// topic: topicName, // add topic send to subscribers
|
||||
// token: FCMtoken // add FCM token to send to specific user
|
||||
}]
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
};
|
||||
|
||||
@@ -276,11 +302,11 @@ export function emptyExtensionObject(
|
||||
requiredFields: [],
|
||||
trackedFields: [],
|
||||
conditions: `const condition: Condition = async({row, change, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
logging.log("condition started")
|
||||
|
||||
return true;
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
|
||||
}`,
|
||||
lastEditor: user,
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@ import { fieldParser } from "@src/components/TableModals/ImportAirtableWizard/ut
|
||||
import Step1Columns from "./Step1Columns";
|
||||
import Step2NewColumns from "./Step2NewColumns";
|
||||
import Step3Preview from "./Step3Preview";
|
||||
import useConverter from "@src/components/TableModals/ImportCsvWizard/useConverter";
|
||||
import useUploadFileFromURL from "@src/components/TableModals/ImportCsvWizard/useUploadFileFromURL";
|
||||
|
||||
export type AirtableConfig = {
|
||||
pairs: { fieldKey: string; columnKey: string }[];
|
||||
@@ -65,6 +67,8 @@ export default function ImportAirtableWizard({ onClose }: ITableModalProps) {
|
||||
newColumns: [],
|
||||
documentId: "recordId",
|
||||
});
|
||||
const { needsUploadTypes, getConverter } = useConverter();
|
||||
const { addTask, runBatchedUpload, hasUploadJobs } = useUploadFileFromURL();
|
||||
|
||||
const updateConfig: IStepProps["updateConfig"] = useCallback((value) => {
|
||||
setConfig((prev) => {
|
||||
@@ -99,10 +103,24 @@ export default function ImportAirtableWizard({ onClose }: ITableModalProps) {
|
||||
const matchingColumn =
|
||||
columns[pair.columnKey] ??
|
||||
find(config.newColumns, { key: pair.columnKey });
|
||||
const parser = fieldParser(matchingColumn.type);
|
||||
const parser =
|
||||
getConverter(matchingColumn.type) || fieldParser(matchingColumn.type);
|
||||
const value = parser
|
||||
? parser(record.fields[pair.fieldKey])
|
||||
: record.fields[pair.fieldKey];
|
||||
|
||||
if (needsUploadTypes(matchingColumn.type)) {
|
||||
if (value && value.length > 0) {
|
||||
addTask({
|
||||
docRef: {
|
||||
path: `${tableSettings.collection}/${record.id}`,
|
||||
id: record.id,
|
||||
},
|
||||
fieldName: pair.columnKey,
|
||||
files: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
return config.documentId === "recordId"
|
||||
? { ...a, [pair.columnKey]: value, _rowy_ref: { id: record.id } }
|
||||
: { ...a, [pair.columnKey]: value };
|
||||
@@ -196,6 +214,10 @@ export default function ImportAirtableWizard({ onClose }: ITableModalProps) {
|
||||
`Imported ${Number(countRef.current).toLocaleString()} rows`,
|
||||
{ variant: "success" }
|
||||
);
|
||||
|
||||
if (hasUploadJobs()) {
|
||||
await runBatchedUpload();
|
||||
}
|
||||
} catch (e) {
|
||||
enqueueSnackbar((e as Error).message, { variant: "error" });
|
||||
} finally {
|
||||
|
||||
@@ -36,6 +36,17 @@ import { FieldType } from "@src/constants/fields";
|
||||
import { getFieldProp } from "@src/components/fields";
|
||||
import { suggestType } from "@src/components/TableModals/ImportAirtableWizard/utils";
|
||||
|
||||
function getFieldKeys(records: any[]) {
|
||||
let fieldKeys = new Set<string>();
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const keys = Object.keys(records[i].fields);
|
||||
for (let j = 0; j < keys.length; j++) {
|
||||
fieldKeys.add(keys[j]);
|
||||
}
|
||||
}
|
||||
return [...fieldKeys];
|
||||
}
|
||||
|
||||
export default function Step1Columns({
|
||||
airtableData,
|
||||
config,
|
||||
@@ -57,9 +68,7 @@ export default function Step1Columns({
|
||||
config.pairs.map((pair) => pair.fieldKey)
|
||||
);
|
||||
|
||||
|
||||
const fieldKeys = Object.keys(airtableData.records[0].fields);
|
||||
|
||||
const fieldKeys = getFieldKeys(airtableData.records);
|
||||
// When a field is selected to be imported
|
||||
const handleSelect =
|
||||
(field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -128,8 +137,8 @@ export default function Step1Columns({
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedFields.length !== fieldKeys.length) {
|
||||
setSelectedFields(fieldKeys)
|
||||
fieldKeys.forEach(field => {
|
||||
setSelectedFields(fieldKeys);
|
||||
fieldKeys.forEach((field) => {
|
||||
// Try to match each field to a column in the table
|
||||
const match =
|
||||
find(tableColumns, (column) =>
|
||||
@@ -158,15 +167,13 @@ export default function Step1Columns({
|
||||
];
|
||||
}
|
||||
updateConfig(columnConfig);
|
||||
})
|
||||
});
|
||||
} else {
|
||||
setSelectedFields([])
|
||||
setConfig((config) => ({ ...config, newColumns: [], pairs: [] }))
|
||||
setSelectedFields([]);
|
||||
setConfig((config) => ({ ...config, newColumns: [], pairs: [] }));
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
// When a field is mapped to a new column
|
||||
const handleChange = (fieldKey: string) => (value: string) => {
|
||||
if (!value) return;
|
||||
@@ -236,7 +243,11 @@ export default function Step1Columns({
|
||||
color="default"
|
||||
/>
|
||||
}
|
||||
label={selectedFields.length == fieldKeys.length ? "Clear all" : "Select all"}
|
||||
label={
|
||||
selectedFields.length === fieldKeys.length
|
||||
? "Clear all"
|
||||
: "Select all"
|
||||
}
|
||||
sx={{
|
||||
height: 42,
|
||||
mr: 0,
|
||||
@@ -251,8 +262,8 @@ export default function Step1Columns({
|
||||
find(config.pairs, { fieldKey: field })?.columnKey ?? null;
|
||||
const matchingColumn = columnKey
|
||||
? tableSchema.columns?.[columnKey] ??
|
||||
find(config.newColumns, { key: columnKey }) ??
|
||||
null
|
||||
find(config.newColumns, { key: columnKey }) ??
|
||||
null
|
||||
: null;
|
||||
const isNewColumn = !!find(config.newColumns, { key: columnKey });
|
||||
return (
|
||||
|
||||
@@ -67,7 +67,7 @@ export const fieldParser = (fieldType: FieldType) => {
|
||||
case FieldType.dateTime:
|
||||
return (v: string) => {
|
||||
const date = parseISO(v);
|
||||
return isValidDate(date) ? date.getTime() : null;
|
||||
return isValidDate(date) ? new Date(date) : null;
|
||||
};
|
||||
default:
|
||||
return (v: any) => v;
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
import { ColumnConfig } from "@src/types/table";
|
||||
import { getFieldProp } from "@src/components/fields";
|
||||
import { analytics, logEvent } from "@src/analytics";
|
||||
import { generateId } from "@src/utils/table";
|
||||
import { isValidDocId } from "./utils";
|
||||
import useUploadFileFromURL from "./useUploadFileFromURL";
|
||||
import useConverter from "./useConverter";
|
||||
|
||||
export type CsvConfig = {
|
||||
pairs: { csvKey: string; columnKey: string }[];
|
||||
@@ -65,6 +68,8 @@ export default function ImportCsvWizard({ onClose }: ITableModalProps) {
|
||||
const theme = useTheme();
|
||||
const isXs = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const snackbarProgressRef = useRef<ISnackbarProgressRef>();
|
||||
const { addTask, runBatchedUpload } = useUploadFileFromURL();
|
||||
const { needsUploadTypes, needsConverter, getConverter } = useConverter();
|
||||
|
||||
const columns = useMemoValue(tableSchema.columns ?? {}, isEqual);
|
||||
|
||||
@@ -74,6 +79,7 @@ export default function ImportCsvWizard({ onClose }: ITableModalProps) {
|
||||
documentId: "auto",
|
||||
documentIdCsvKey: null,
|
||||
});
|
||||
|
||||
const updateConfig: IStepProps["updateConfig"] = useCallback((value) => {
|
||||
setConfig((prev) => {
|
||||
const pairs = uniqBy([...prev.pairs, ...(value.pairs ?? [])], "csvKey");
|
||||
@@ -123,6 +129,36 @@ export default function ImportCsvWizard({ onClose }: ITableModalProps) {
|
||||
)
|
||||
: { validRows: parsedRows, invalidRows: [] };
|
||||
|
||||
const { requiredConverts, requiredUploads } = useMemo(() => {
|
||||
const columns = config.pairs.map(({ csvKey, columnKey }) => ({
|
||||
csvKey,
|
||||
columnKey,
|
||||
...(tableSchema.columns?.[columnKey] ??
|
||||
find(config.newColumns, { key: columnKey }) ??
|
||||
{}),
|
||||
}));
|
||||
|
||||
let requiredConverts: any = {};
|
||||
let requiredUploads: any = {};
|
||||
columns.forEach((column, index) => {
|
||||
if (needsConverter(column.type)) {
|
||||
requiredConverts[index] = getConverter(column.type);
|
||||
// console.log({ needsUploadTypes }, column.type);
|
||||
if (needsUploadTypes(column.type)) {
|
||||
requiredUploads[column.fieldName + ""] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
return { requiredConverts, requiredUploads };
|
||||
}, [
|
||||
config.newColumns,
|
||||
config.pairs,
|
||||
getConverter,
|
||||
needsConverter,
|
||||
needsUploadTypes,
|
||||
tableSchema.columns,
|
||||
]);
|
||||
|
||||
const handleFinish = async () => {
|
||||
if (!parsedRows) return;
|
||||
console.time("importCsv");
|
||||
@@ -176,12 +212,48 @@ export default function ImportCsvWizard({ onClose }: ITableModalProps) {
|
||||
{ variant: "warning" }
|
||||
);
|
||||
}
|
||||
const newValidRows = validRows.map((row) => {
|
||||
// Convert required values
|
||||
Object.keys(row).forEach((key, i) => {
|
||||
if (requiredConverts[i]) {
|
||||
row[key] = requiredConverts[i](row[key]);
|
||||
}
|
||||
});
|
||||
|
||||
const id = generateId();
|
||||
const newRow = {
|
||||
_rowy_ref: {
|
||||
path: `${tableSettings.collection}/${row?._rowy_ref?.id ?? id}`,
|
||||
id,
|
||||
},
|
||||
...row,
|
||||
};
|
||||
return newRow;
|
||||
});
|
||||
|
||||
promises.push(
|
||||
bulkAddRows({
|
||||
rows: validRows,
|
||||
type: "add",
|
||||
rows: newValidRows,
|
||||
collection: tableSettings.collection,
|
||||
onBatchCommit: (batchNumber: number) =>
|
||||
snackbarProgressRef.current?.setProgress(batchNumber),
|
||||
onBatchCommit: async (batchNumber: number) => {
|
||||
if (Object.keys(requiredUploads).length > 0) {
|
||||
newValidRows
|
||||
.slice((batchNumber - 1) * 500, batchNumber * 500 - 1)
|
||||
.forEach((row) => {
|
||||
Object.keys(requiredUploads).forEach((key) => {
|
||||
if (requiredUploads[key]) {
|
||||
addTask({
|
||||
docRef: row._rowy_ref,
|
||||
fieldName: key,
|
||||
files: row[key],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
snackbarProgressRef.current?.setProgress(batchNumber);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -192,6 +264,9 @@ export default function ImportCsvWizard({ onClose }: ITableModalProps) {
|
||||
`Imported ${Number(validRows.length).toLocaleString()} rows`,
|
||||
{ variant: "success" }
|
||||
);
|
||||
if (Object.keys(requiredUploads).length > 0) {
|
||||
await runBatchedUpload();
|
||||
}
|
||||
} catch (e) {
|
||||
enqueueSnackbar((e as Error).message, { variant: "error" });
|
||||
} finally {
|
||||
@@ -248,7 +323,9 @@ export default function ImportCsvWizard({ onClose }: ITableModalProps) {
|
||||
disableNext:
|
||||
config.pairs.length === 0 ||
|
||||
!validRows ||
|
||||
(config.documentId === "column" && !config.documentIdCsvKey),
|
||||
(config.documentId === "column" && !config.documentIdCsvKey) ||
|
||||
config.pairs.some((pair) => !pair.columnKey) ||
|
||||
config.newColumns.some((col) => !col.key),
|
||||
},
|
||||
config.newColumns.length > 0 && {
|
||||
title: "Set column types",
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function Step1Columns({
|
||||
const handleSelectAll = () => {
|
||||
if (selectedFields.length !== csvData.columns.length) {
|
||||
setSelectedFields(csvData.columns);
|
||||
csvData.columns.forEach(field => {
|
||||
csvData.columns.forEach((field) => {
|
||||
// Try to match each field to a column in the table
|
||||
const match =
|
||||
find(tableColumns, (column) =>
|
||||
@@ -89,10 +89,10 @@ export default function Step1Columns({
|
||||
];
|
||||
}
|
||||
updateConfig(columnConfig);
|
||||
})
|
||||
});
|
||||
} else {
|
||||
setSelectedFields([])
|
||||
setConfig((config) => ({ ...config, newColumns: [], pairs: [] }))
|
||||
setSelectedFields([]);
|
||||
setConfig((config) => ({ ...config, newColumns: [], pairs: [] }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,7 +232,11 @@ export default function Step1Columns({
|
||||
color="default"
|
||||
/>
|
||||
}
|
||||
label={selectedFields.length == csvData.columns.length ? "Clear all" : "Select all"}
|
||||
label={
|
||||
selectedFields.length === csvData.columns.length
|
||||
? "Clear all"
|
||||
: "Select all"
|
||||
}
|
||||
sx={{
|
||||
height: 42,
|
||||
mr: 0,
|
||||
@@ -247,14 +251,22 @@ export default function Step1Columns({
|
||||
find(config.pairs, { csvKey: field })?.columnKey ?? null;
|
||||
const matchingColumn = columnKey
|
||||
? tableSchema.columns?.[columnKey] ??
|
||||
find(config.newColumns, { key: columnKey }) ??
|
||||
null
|
||||
find(config.newColumns, { key: columnKey }) ??
|
||||
null
|
||||
: null;
|
||||
const isNewColumn = !!find(config.newColumns, { key: columnKey });
|
||||
|
||||
return (
|
||||
<Grid container key={field} component="li" wrap="nowrap">
|
||||
<Grid item xs>
|
||||
<Grid
|
||||
container
|
||||
key={field}
|
||||
component="li"
|
||||
wrap="nowrap"
|
||||
sx={{
|
||||
marginTop: "36px !important",
|
||||
}}
|
||||
>
|
||||
<Grid container item xs alignItems={"center"}>
|
||||
<FormControlLabel
|
||||
key={field}
|
||||
control={
|
||||
@@ -287,88 +299,145 @@ export default function Step1Columns({
|
||||
<ArrowIcon color="disabled" sx={{ color: "secondary.main" }} />
|
||||
</Grid>
|
||||
|
||||
<Grid item xs>
|
||||
<Grid item container spacing={4} xs alignItems={"center"}>
|
||||
{selected && (
|
||||
<ColumnSelect
|
||||
multiple={false}
|
||||
value={columnKey}
|
||||
onChange={handleChange(field) as any}
|
||||
TextFieldProps={{
|
||||
hiddenLabel: true,
|
||||
SelectProps: {
|
||||
renderValue: () => {
|
||||
if (!columnKey) return "Select or add column";
|
||||
else
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
>
|
||||
<Box sx={{ width: 24, height: 24 }}>
|
||||
{!isNewColumn ? (
|
||||
getFieldProp("icon", matchingColumn?.type)
|
||||
) : (
|
||||
<TableColumnIcon color="disabled" />
|
||||
)}
|
||||
</Box>
|
||||
{matchingColumn?.name}
|
||||
{isNewColumn && (
|
||||
<Chip
|
||||
label="New"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
pointerEvents: "none",
|
||||
height: 24,
|
||||
fontWeight: "normal",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
sx: [
|
||||
{
|
||||
backgroundColor: "background.default",
|
||||
border: (theme) =>
|
||||
`1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: "none",
|
||||
"& .MuiSelect-select": {
|
||||
boxSizing: "border-box",
|
||||
height: COLUMN_HEADER_HEIGHT - 2,
|
||||
typography: "caption",
|
||||
fontWeight: "medium",
|
||||
lineHeight: "28px",
|
||||
<>
|
||||
<Grid item xs>
|
||||
<ColumnSelect
|
||||
multiple={false}
|
||||
value={columnKey}
|
||||
onChange={handleChange(field) as any}
|
||||
TextFieldProps={{
|
||||
hiddenLabel: true,
|
||||
SelectProps: {
|
||||
renderValue: () => {
|
||||
if (!columnKey) return "Select or add column";
|
||||
else
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
>
|
||||
<Box sx={{ width: 24, height: 24 }}>
|
||||
{!isNewColumn ? (
|
||||
getFieldProp(
|
||||
"icon",
|
||||
matchingColumn?.type
|
||||
)
|
||||
) : (
|
||||
<TableColumnIcon color="disabled" />
|
||||
)}
|
||||
</Box>
|
||||
{matchingColumn?.name}
|
||||
{isNewColumn && (
|
||||
<Chip
|
||||
label="New"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
pointerEvents: "none",
|
||||
height: 24,
|
||||
fontWeight: "normal",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
sx: [
|
||||
{
|
||||
backgroundColor: "background.default",
|
||||
border: (theme) =>
|
||||
`1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: "none",
|
||||
"& .MuiSelect-select": {
|
||||
boxSizing: "border-box",
|
||||
height: COLUMN_HEADER_HEIGHT - 2,
|
||||
typography: "caption",
|
||||
fontWeight: "medium",
|
||||
lineHeight: "28px",
|
||||
},
|
||||
|
||||
color: "text.secondary",
|
||||
"&:hover": {
|
||||
backgroundColor: "background.default",
|
||||
color: "text.primary",
|
||||
boxShadow: "none",
|
||||
},
|
||||
color: "text.secondary",
|
||||
"&:hover": {
|
||||
backgroundColor: "background.default",
|
||||
color: "text.primary",
|
||||
boxShadow: "none",
|
||||
},
|
||||
|
||||
"&::before": { content: "none" },
|
||||
"&::after": { pointerEvents: "none" },
|
||||
"&::before": { content: "none" },
|
||||
"&::after": { pointerEvents: "none" },
|
||||
},
|
||||
!columnKey && { color: "text.disabled" },
|
||||
],
|
||||
},
|
||||
!columnKey && { color: "text.disabled" },
|
||||
],
|
||||
},
|
||||
sx: { "& .MuiInputLabel-root": { display: "none" } },
|
||||
}}
|
||||
clearable={false}
|
||||
displayEmpty
|
||||
freeText
|
||||
AddButtonProps={{ children: "Create column…" }}
|
||||
AddDialogProps={{
|
||||
title: "Create column",
|
||||
textFieldLabel: "Column name",
|
||||
}}
|
||||
/>
|
||||
sx: { "& .MuiInputLabel-root": { display: "none" } },
|
||||
}}
|
||||
clearable={false}
|
||||
displayEmpty
|
||||
freeText
|
||||
AddButtonProps={{ children: "Create column…" }}
|
||||
AddDialogProps={{
|
||||
title: "Create column",
|
||||
textFieldLabel: "Column name",
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<TextField
|
||||
label="Field key"
|
||||
value={
|
||||
config.pairs.find(
|
||||
(pair) => pair.columnKey === columnKey
|
||||
)?.columnKey ??
|
||||
config.newColumns.find(
|
||||
(pair) => pair.key === columnKey
|
||||
)?.key
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newKey = e.target.value;
|
||||
const newPairs = config.pairs.map((pair) => {
|
||||
if (pair.columnKey === columnKey) {
|
||||
return { ...pair, columnKey: newKey };
|
||||
} else {
|
||||
return pair;
|
||||
}
|
||||
});
|
||||
|
||||
const newColumns = config.newColumns.map((column) => {
|
||||
if (column.key === columnKey) {
|
||||
return {
|
||||
...column,
|
||||
key: newKey,
|
||||
fieldName: newKey,
|
||||
};
|
||||
} else {
|
||||
return column;
|
||||
}
|
||||
});
|
||||
|
||||
setConfig((config) => ({
|
||||
...config,
|
||||
pairs: newPairs,
|
||||
newColumns,
|
||||
}));
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
position: "absolute",
|
||||
transform: "translateY(-100%)",
|
||||
},
|
||||
"& .MuiInputBase-root": {
|
||||
height: 40,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
160
src/components/TableModals/ImportCsvWizard/useConverter.ts
Normal file
160
src/components/TableModals/ImportCsvWizard/useConverter.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { projectScope } from "@src/atoms/projectScope";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
|
||||
import {
|
||||
doc,
|
||||
DocumentReference as Reference,
|
||||
GeoPoint,
|
||||
} from "firebase/firestore";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
const needsConverter = (type: FieldType) =>
|
||||
[
|
||||
FieldType.image,
|
||||
FieldType.reference,
|
||||
FieldType.file,
|
||||
FieldType.geoPoint,
|
||||
].includes(type);
|
||||
|
||||
const needsUploadTypes = (type: FieldType) =>
|
||||
[FieldType.image, FieldType.file].includes(type);
|
||||
|
||||
export default function useConverter() {
|
||||
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
|
||||
|
||||
const referenceConverter = (value: string): Reference | null => {
|
||||
if (!value) return null;
|
||||
if (value.charAt(value.length - 1) === "/") {
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
if (value.split("/").length % 2 === 0) {
|
||||
try {
|
||||
return doc(firebaseDb, value);
|
||||
} catch (e) {
|
||||
console.log("error", e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const imageOrFileConverter = (urls: any): RowyFile[] => {
|
||||
try {
|
||||
if (!urls) return [];
|
||||
if (Array.isArray(urls)) {
|
||||
return urls
|
||||
.map((url) => {
|
||||
if (typeof url === "string") {
|
||||
url = url.trim();
|
||||
if (url !== "") {
|
||||
return {
|
||||
downloadURL: url,
|
||||
name: url.split("/").pop() || "",
|
||||
lastModifiedTS: +new Date(),
|
||||
type: "",
|
||||
};
|
||||
}
|
||||
} else if (url && typeof url === "object" && url.downloadURL) {
|
||||
return url;
|
||||
} else {
|
||||
if (url.url) {
|
||||
return {
|
||||
downloadURL: url.url,
|
||||
name: url.filename || url.url.split("/").pop() || "",
|
||||
lastModifiedTS: +new Date(),
|
||||
type: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((val) => val !== null) as RowyFile[];
|
||||
}
|
||||
if (typeof urls === "string") {
|
||||
return urls
|
||||
.split(",")
|
||||
.map((url) => {
|
||||
url = url.trim();
|
||||
if (url !== "") {
|
||||
return {
|
||||
downloadURL: url,
|
||||
name: url.split("/").pop() || "",
|
||||
lastModifiedTS: +new Date(),
|
||||
type: "",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((val) => val !== null) as RowyFile[];
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const geoPointConverter = (value: any) => {
|
||||
if (!value) return null;
|
||||
if (typeof value === "string") {
|
||||
let latitude, longitude;
|
||||
// covered cases:
|
||||
// [3.2, 32.3]
|
||||
// {latitude: 3.2, longitude: 32.3}
|
||||
// "3.2, 32.3"
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
[latitude, longitude] = parsed;
|
||||
} else {
|
||||
latitude = parsed.latitude;
|
||||
longitude = parsed.longitude;
|
||||
}
|
||||
|
||||
if (latitude && longitude) {
|
||||
latitude = parseFloat(latitude);
|
||||
longitude = parseFloat(longitude);
|
||||
}
|
||||
} catch (e) {
|
||||
[latitude, longitude] = value
|
||||
.split(",")
|
||||
.map((val) => parseFloat(val.trim()));
|
||||
}
|
||||
|
||||
if (latitude && longitude) {
|
||||
return new GeoPoint(latitude, longitude);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getConverter = (type: FieldType) => {
|
||||
switch (type) {
|
||||
case FieldType.image:
|
||||
case FieldType.file:
|
||||
return imageOrFileConverter;
|
||||
case FieldType.reference:
|
||||
return referenceConverter;
|
||||
case FieldType.geoPoint:
|
||||
return geoPointConverter;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const checkAndConvert = (value: any, type: FieldType) => {
|
||||
if (needsConverter(type)) {
|
||||
const converter = getConverter(type);
|
||||
if (converter) return converter(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
needsConverter,
|
||||
referenceConverter,
|
||||
imageOrFileConverter,
|
||||
getConverter,
|
||||
checkAndConvert,
|
||||
needsUploadTypes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { SnackbarKey, useSnackbar } from "notistack";
|
||||
import Button from "@mui/material/Button";
|
||||
|
||||
import useUploader from "@src/hooks/useFirebaseStorageUploader";
|
||||
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
|
||||
import { TableRowRef } from "@src/types/table";
|
||||
import SnackbarProgress from "@src/components/SnackbarProgress";
|
||||
|
||||
const MAX_CONCURRENT_TASKS = 1000;
|
||||
|
||||
type UploadParamTypes = {
|
||||
docRef: TableRowRef;
|
||||
fieldName: string;
|
||||
files: RowyFile[];
|
||||
};
|
||||
|
||||
export default function useUploadFileFromURL() {
|
||||
const { upload } = useUploader();
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
|
||||
const jobs = useRef<UploadParamTypes[]>([]);
|
||||
|
||||
const askPermission = useCallback(async (): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
enqueueSnackbar("Upload files to firebase storage?", {
|
||||
persist: true,
|
||||
preventDuplicate: true,
|
||||
action: (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
closeSnackbar();
|
||||
resolve(true);
|
||||
}}
|
||||
style={{
|
||||
marginRight: 8,
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
closeSnackbar();
|
||||
resolve(false);
|
||||
}}
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
});
|
||||
});
|
||||
}, [enqueueSnackbar, closeSnackbar]);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async ({
|
||||
docRef,
|
||||
fieldName,
|
||||
files,
|
||||
}: UploadParamTypes): Promise<boolean> => {
|
||||
try {
|
||||
const files_ = await getFileFromURL(
|
||||
files.map((file) => file.downloadURL)
|
||||
);
|
||||
const { uploads, failures } = await upload({
|
||||
docRef,
|
||||
fieldName,
|
||||
files: files_,
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
return false;
|
||||
}
|
||||
await updateField({
|
||||
path: docRef.path,
|
||||
fieldName,
|
||||
value: uploads,
|
||||
useArrayUnion: false,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[upload, updateField]
|
||||
);
|
||||
|
||||
const batchUpload = useCallback(
|
||||
async (batch: UploadParamTypes[]) => {
|
||||
await Promise.all(
|
||||
batch.map((job) =>
|
||||
handleUpload(job).then(() => {
|
||||
snackbarProgressRef.current?.setProgress((p: number) => p + 1);
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
[handleUpload]
|
||||
);
|
||||
|
||||
const snackbarProgressRef = useRef<any>(null);
|
||||
const snackbarProgressId = useRef<SnackbarKey | null>(null);
|
||||
const showProgress = useCallback(
|
||||
(totalJobs: number) => {
|
||||
snackbarProgressId.current = enqueueSnackbar(
|
||||
`Uploading files form ${Number(
|
||||
totalJobs
|
||||
).toLocaleString()} cells. This might take a while.`,
|
||||
{
|
||||
persist: true,
|
||||
action: (
|
||||
<SnackbarProgress
|
||||
stateRef={snackbarProgressRef}
|
||||
target={totalJobs}
|
||||
label=" completed"
|
||||
/>
|
||||
),
|
||||
}
|
||||
);
|
||||
},
|
||||
[enqueueSnackbar]
|
||||
);
|
||||
|
||||
const runBatchedUpload = useCallback(async () => {
|
||||
if (!snackbarProgressId.current) {
|
||||
showProgress(jobs.current.length);
|
||||
}
|
||||
let currentJobs: UploadParamTypes[] = [];
|
||||
|
||||
while (
|
||||
currentJobs.length < MAX_CONCURRENT_TASKS &&
|
||||
jobs.current.length > 0
|
||||
) {
|
||||
const job = jobs.current.shift();
|
||||
if (job) {
|
||||
currentJobs.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
await batchUpload(currentJobs);
|
||||
|
||||
if (jobs.current.length > 0) {
|
||||
await runBatchedUpload();
|
||||
}
|
||||
|
||||
if (snackbarProgressId.current) {
|
||||
closeSnackbar(snackbarProgressId.current);
|
||||
}
|
||||
}, [batchUpload, closeSnackbar, showProgress, snackbarProgressId]);
|
||||
|
||||
const addTask = useCallback((job: UploadParamTypes) => {
|
||||
jobs.current.push(job);
|
||||
}, []);
|
||||
|
||||
const hasUploadJobs = () => jobs.current.length > 0;
|
||||
return {
|
||||
addTask,
|
||||
runBatchedUpload,
|
||||
askPermission,
|
||||
hasUploadJobs,
|
||||
};
|
||||
}
|
||||
|
||||
function getFileFromURL(urls: string[]): Promise<File[]> {
|
||||
const promises = urls.map((url) => {
|
||||
return fetch(url)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => new File([blob], +new Date() + "", { type: blob.type }));
|
||||
});
|
||||
return Promise.all(promises);
|
||||
}
|
||||
@@ -117,7 +117,11 @@ export default function Step1Columns({ config, setConfig }: IStepProps) {
|
||||
color="default"
|
||||
/>
|
||||
}
|
||||
label={selectedFields.length == allFields.length ? "Clear all" : "Select all"}
|
||||
label={
|
||||
selectedFields.length === allFields.length
|
||||
? "Clear all"
|
||||
: "Select all"
|
||||
}
|
||||
sx={{
|
||||
height: 42,
|
||||
mr: 0,
|
||||
|
||||
@@ -18,14 +18,21 @@ export const SELECTABLE_TYPES = [
|
||||
FieldType.url,
|
||||
FieldType.rating,
|
||||
|
||||
FieldType.image,
|
||||
FieldType.file,
|
||||
|
||||
FieldType.singleSelect,
|
||||
FieldType.multiSelect,
|
||||
|
||||
FieldType.json,
|
||||
FieldType.code,
|
||||
|
||||
FieldType.geoPoint,
|
||||
|
||||
FieldType.color,
|
||||
FieldType.slider,
|
||||
|
||||
FieldType.reference,
|
||||
];
|
||||
|
||||
export const REGEX_EMAIL =
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import WarningIcon from "@mui/icons-material/WarningAmber";
|
||||
import { TableSettings } from "@src/types/table";
|
||||
import {
|
||||
IWebhook,
|
||||
ISecret,
|
||||
} from "@src/components/TableModals/WebhooksModal/utils";
|
||||
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
|
||||
|
||||
const requestType = [
|
||||
"declare type WebHookRequest {",
|
||||
@@ -68,8 +65,8 @@ export const webhookBasic = {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("basicParser started")
|
||||
|
||||
// Import any NPM package needed
|
||||
// const lodash = require('lodash');
|
||||
// Import NPM package needed, some packages may not work in Webhooks
|
||||
// const {default: lodash} = await import("lodash");
|
||||
|
||||
// Optionally return an object to be added as a new row to the table
|
||||
// Example: add the webhook body as row
|
||||
@@ -101,11 +98,7 @@ export const webhookBasic = {
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`,
|
||||
},
|
||||
auth: (
|
||||
webhookObject: IWebhook,
|
||||
setWebhookObject: (w: IWebhook) => void,
|
||||
secrets: ISecret
|
||||
) => {
|
||||
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
|
||||
return (
|
||||
<Typography color="text.disabled">
|
||||
<WarningIcon aria-label="Warning" style={{ verticalAlign: "bottom" }} />
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Typography, Link, TextField } from "@mui/material";
|
||||
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||
import { TableSettings } from "@src/types/table";
|
||||
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
|
||||
|
||||
export const webhookFirebaseAuth = {
|
||||
name: "firebaseAuth",
|
||||
parser: {
|
||||
additionalVariables: null,
|
||||
extraLibs: null,
|
||||
template: (
|
||||
table: TableSettings
|
||||
) => `const firebaseAuthParser: Parser = async({req, db, ref, logging}) =>{
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("firebaseAuthParser started")
|
||||
/**
|
||||
* This is a sample parser for firebase authentication
|
||||
* creates a user document in the collection if it doesn't exist
|
||||
// check if document exists,
|
||||
const userDoc = await ref.doc(user.uid).get()
|
||||
if(!userDoc.exists){
|
||||
await ref.doc(user.uid).set({email:user.email})
|
||||
}
|
||||
*/
|
||||
return;
|
||||
};`,
|
||||
},
|
||||
condition: {
|
||||
additionalVariables: null,
|
||||
extraLibs: null,
|
||||
template: (
|
||||
table: TableSettings
|
||||
) => `const condition: Condition = async({ref, req, db, logging}) => {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("condition started")
|
||||
|
||||
return true;
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`,
|
||||
},
|
||||
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
|
||||
return (
|
||||
<>
|
||||
<Typography variant="inherit" paragraph>
|
||||
For Firebase authentication, you need to include the following header
|
||||
in your request:
|
||||
<br />
|
||||
<code>Authorization: Bear ACCESS_TOKEN</code>
|
||||
</Typography>
|
||||
|
||||
<Typography variant="inherit" paragraph>
|
||||
Once enabled requests without a valid token will return{" "}
|
||||
<code>401</code> response.
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default webhookFirebaseAuth;
|
||||
@@ -1,7 +1,8 @@
|
||||
import basic from "./basic";
|
||||
import firebaseAuth from "./firebaseAuth";
|
||||
import typeform from "./typeform";
|
||||
import sendgrid from "./sendgrid";
|
||||
import webform from "./webform";
|
||||
import stripe from "./stripe";
|
||||
|
||||
export { basic, typeform, sendgrid, webform, stripe };
|
||||
export { basic, typeform, sendgrid, webform, stripe, firebaseAuth };
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Typography, Link, TextField } from "@mui/material";
|
||||
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||
import { TableSettings } from "@src/types/table";
|
||||
import {
|
||||
IWebhook,
|
||||
ISecret,
|
||||
} from "@src/components/TableModals/WebhooksModal/utils";
|
||||
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
|
||||
|
||||
export const webhookSendgrid = {
|
||||
name: "SendGrid",
|
||||
@@ -17,8 +14,8 @@ export const webhookSendgrid = {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("sendgridParser started")
|
||||
|
||||
// Import any NPM package needed
|
||||
// const lodash = require('lodash');
|
||||
// Import NPM package needed, some packages may not work in Webhooks
|
||||
// const {default: lodash} = await import("lodash");
|
||||
|
||||
const { body } = req
|
||||
const eventHandler = async (sgEvent) => {
|
||||
@@ -51,11 +48,7 @@ export const webhookSendgrid = {
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`,
|
||||
},
|
||||
auth: (
|
||||
webhookObject: IWebhook,
|
||||
setWebhookObject: (w: IWebhook) => void,
|
||||
secrets: ISecret
|
||||
) => {
|
||||
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
|
||||
return (
|
||||
<>
|
||||
<Typography gutterBottom>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Typography, Link, TextField, Alert } from "@mui/material";
|
||||
import { useAtom } from "jotai";
|
||||
import { Typography, Link, TextField, Alert, Box } from "@mui/material";
|
||||
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||
import { TableSettings } from "@src/types/table";
|
||||
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
|
||||
import {
|
||||
IWebhook,
|
||||
ISecret,
|
||||
} from "@src/components/TableModals/WebhooksModal/utils";
|
||||
projectScope,
|
||||
secretNamesAtom,
|
||||
updateSecretNamesAtom,
|
||||
projectSettingsDialogAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import InputLabel from "@mui/material/InputLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import Select from "@mui/material/Select";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
|
||||
export const webhookStripe = {
|
||||
name: "Stripe",
|
||||
@@ -21,8 +26,8 @@ export const webhookStripe = {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("stripeParser started")
|
||||
|
||||
// Import any NPM package needed
|
||||
// const lodash = require('lodash');
|
||||
// Import NPM package needed, some packages may not work in Webhooks
|
||||
// const {default: lodash} = await import("lodash");
|
||||
|
||||
const event = req.body
|
||||
switch (event.type) {
|
||||
@@ -49,11 +54,14 @@ export const webhookStripe = {
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`,
|
||||
},
|
||||
auth: (
|
||||
webhookObject: IWebhook,
|
||||
setWebhookObject: (w: IWebhook) => void,
|
||||
secrets: ISecret
|
||||
) => {
|
||||
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
|
||||
const [secretNames] = useAtom(secretNamesAtom, projectScope);
|
||||
const [updateSecretNames] = useAtom(updateSecretNamesAtom, projectScope);
|
||||
const [{ open, tab }, setProjectSettingsDialog] = useAtom(
|
||||
projectSettingsDialogAtom,
|
||||
projectScope
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography gutterBottom>
|
||||
@@ -77,8 +85,9 @@ export const webhookStripe = {
|
||||
</Typography>
|
||||
|
||||
{webhookObject.auth.secretKey &&
|
||||
!secrets.loading &&
|
||||
!secrets.keys.includes(webhookObject.auth.secretKey) && (
|
||||
!secretNames.loading &&
|
||||
secretNames.secretNames &&
|
||||
!secretNames.secretNames.includes(webhookObject.auth.secretKey) && (
|
||||
<Alert severity="error" sx={{ height: "auto!important" }}>
|
||||
Your previously selected key{" "}
|
||||
<code>{webhookObject.auth.secretKey}</code> does not exist in
|
||||
@@ -86,34 +95,56 @@ export const webhookStripe = {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth margin={"normal"}>
|
||||
<InputLabel id="stripe-secret-key">Secret key</InputLabel>
|
||||
<Select
|
||||
labelId="stripe-secret-key"
|
||||
id="stripe-secret-key"
|
||||
label="Secret key"
|
||||
variant="filled"
|
||||
value={webhookObject.auth.secretKey}
|
||||
onChange={(e) => {
|
||||
setWebhookObject({
|
||||
...webhookObject,
|
||||
auth: { ...webhookObject.auth, secretKey: e.target.value },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{secrets.keys.map((secret) => {
|
||||
return <MenuItem value={secret}>{secret}</MenuItem>;
|
||||
})}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
const secretManagerLink = `https://console.cloud.google.com/security/secret-manager/create?project=${secrets.projectId}`;
|
||||
window?.open?.(secretManagerLink, "_blank")?.focus();
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginY: 1,
|
||||
}}
|
||||
>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="stripe-secret-key">Secret key</InputLabel>
|
||||
<Select
|
||||
labelId="stripe-secret-key"
|
||||
id="stripe-secret-key"
|
||||
label="Secret key"
|
||||
variant="filled"
|
||||
value={webhookObject.auth.secretKey}
|
||||
onChange={(e) => {
|
||||
setWebhookObject({
|
||||
...webhookObject,
|
||||
auth: { ...webhookObject.auth, secretKey: e.target.value },
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add a key in Secret Manager
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{secretNames.secretNames?.map((secret) => {
|
||||
return <MenuItem value={secret}>{secret}</MenuItem>;
|
||||
})}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setProjectSettingsDialog({
|
||||
open: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add a key in Secret Manager
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<LoadingButton
|
||||
sx={{
|
||||
height: "100%",
|
||||
marginLeft: 1,
|
||||
}}
|
||||
loading={secretNames.loading}
|
||||
onClick={() => {
|
||||
updateSecretNames?.();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</LoadingButton>
|
||||
</Box>
|
||||
<TextField
|
||||
id="stripe-signing-secret"
|
||||
label="Signing key"
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Typography, Link, TextField } from "@mui/material";
|
||||
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||
import { TableSettings } from "@src/types/table";
|
||||
import {
|
||||
IWebhook,
|
||||
ISecret,
|
||||
} from "@src/components/TableModals/WebhooksModal/utils";
|
||||
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
|
||||
|
||||
export const webhookTypeform = {
|
||||
name: "Typeform",
|
||||
@@ -17,8 +14,8 @@ export const webhookTypeform = {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("typeformParser started")
|
||||
|
||||
// Import any NPM package needed
|
||||
// const lodash = require('lodash');
|
||||
// Import NPM package needed, some packages may not work in Webhooks
|
||||
// const {default: lodash} = await import("lodash");
|
||||
|
||||
// This reduces the form submission into a single object of key value pairs
|
||||
// Example: {name: "John", age: 20}
|
||||
@@ -83,11 +80,7 @@ export const webhookTypeform = {
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`,
|
||||
},
|
||||
auth: (
|
||||
webhookObject: IWebhook,
|
||||
setWebhookObject: (w: IWebhook) => void,
|
||||
secrets: ISecret
|
||||
) => {
|
||||
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
|
||||
return (
|
||||
<>
|
||||
<Typography gutterBottom>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Typography, Link, TextField } from "@mui/material";
|
||||
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||
import { TableSettings } from "@src/types/table";
|
||||
import {
|
||||
IWebhook,
|
||||
ISecret,
|
||||
} from "@src/components/TableModals/WebhooksModal/utils";
|
||||
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
|
||||
|
||||
export const webhook = {
|
||||
name: "Web Form",
|
||||
@@ -18,8 +15,8 @@ export const webhook = {
|
||||
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
logging.log("formParser started")
|
||||
|
||||
// Import any NPM package needed
|
||||
// const lodash = require('lodash');
|
||||
// Import NPM package needed, some packages may not work in Webhooks
|
||||
// const {default: lodash} = await import("lodash");
|
||||
|
||||
// Optionally return an object to be added as a new row to the table
|
||||
// Example: add the webhook body as row
|
||||
@@ -51,11 +48,7 @@ export const webhook = {
|
||||
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
|
||||
}`,
|
||||
},
|
||||
auth: (
|
||||
webhookObject: IWebhook,
|
||||
setWebhookObject: (w: IWebhook) => void,
|
||||
secrets: ISecret
|
||||
) => {
|
||||
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
|
||||
return (
|
||||
<>
|
||||
<Typography gutterBottom>
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { IWebhookModalStepProps } from "./WebhookModal";
|
||||
|
||||
import { FormControlLabel, Checkbox, Typography } from "@mui/material";
|
||||
|
||||
import {
|
||||
projectIdAtom,
|
||||
projectScope,
|
||||
rowyRunAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import { runRoutes } from "@src/constants/runRoutes";
|
||||
import { webhookSchemas, ISecret } from "./utils";
|
||||
import { webhookSchemas } from "./utils";
|
||||
|
||||
export default function Step1Endpoint({
|
||||
webhookObject,
|
||||
setWebhookObject,
|
||||
}: IWebhookModalStepProps) {
|
||||
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
|
||||
const [projectId] = useAtom(projectIdAtom, projectScope);
|
||||
const [secrets, setSecrets] = useState<ISecret>({
|
||||
loading: true,
|
||||
keys: [],
|
||||
projectId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
rowyRun({
|
||||
route: runRoutes.listSecrets,
|
||||
}).then((secrets) => {
|
||||
setSecrets({
|
||||
loading: false,
|
||||
keys: secrets as string[],
|
||||
projectId,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="inherit" paragraph>
|
||||
@@ -63,10 +35,9 @@ export default function Step1Endpoint({
|
||||
/>
|
||||
|
||||
{webhookObject.auth?.enabled &&
|
||||
webhookSchemas[webhookObject.type].auth(
|
||||
webhookSchemas[webhookObject.type].Auth(
|
||||
webhookObject,
|
||||
setWebhookObject,
|
||||
secrets
|
||||
setWebhookObject
|
||||
)}
|
||||
{}
|
||||
</>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { TableSettings } from "@src/types/table";
|
||||
import { generateId } from "@src/utils/table";
|
||||
import { typeform, basic, sendgrid, webform, stripe } from "./Schemas";
|
||||
import {
|
||||
typeform,
|
||||
basic,
|
||||
sendgrid,
|
||||
webform,
|
||||
stripe,
|
||||
firebaseAuth,
|
||||
} from "./Schemas";
|
||||
|
||||
export const webhookTypes = [
|
||||
"basic",
|
||||
"typeform",
|
||||
"sendgrid",
|
||||
"webform",
|
||||
"firebaseAuth",
|
||||
//"shopify",
|
||||
//"twitter",
|
||||
"stripe",
|
||||
@@ -35,6 +43,18 @@ export const parserExtraLibs = [
|
||||
send: (v:any)=>void;
|
||||
sendStatus: (status:number)=>void
|
||||
};
|
||||
user: {
|
||||
uid: string;
|
||||
email: string;
|
||||
email_verified: boolean;
|
||||
exp: number;
|
||||
iat: number;
|
||||
iss: string;
|
||||
aud: string;
|
||||
auth_time: number;
|
||||
phone_number: string;
|
||||
picture: string;
|
||||
} | undefined;
|
||||
logging: RowyLogging;
|
||||
auth:firebaseauth.BaseAuth;
|
||||
storage:firebasestorage.Storage;
|
||||
@@ -71,6 +91,7 @@ export type WebhookType = typeof webhookTypes[number];
|
||||
export const webhookNames: Record<WebhookType, string> = {
|
||||
sendgrid: "SendGrid",
|
||||
typeform: "Typeform",
|
||||
firebaseAuth: "Firebase Auth",
|
||||
//github:"GitHub",
|
||||
// shopify: "Shopify",
|
||||
// twitter: "Twitter",
|
||||
@@ -98,18 +119,13 @@ export interface IWebhook {
|
||||
auth?: any;
|
||||
}
|
||||
|
||||
export interface ISecret {
|
||||
loading: boolean;
|
||||
keys: string[];
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const webhookSchemas = {
|
||||
basic,
|
||||
typeform,
|
||||
sendgrid,
|
||||
webform,
|
||||
stripe,
|
||||
firebaseAuth,
|
||||
};
|
||||
|
||||
export function emptyWebhookObject(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Control } from "react-hook-form";
|
||||
import { useSetAtom } from "jotai";
|
||||
import type { UseFormReturn, FieldValues } from "react-hook-form";
|
||||
|
||||
import { IconButton, Menu, MenuItem } from "@mui/material";
|
||||
import { IconButton, Menu, MenuItem, Tooltip } from "@mui/material";
|
||||
import { Export as ExportIcon, Import as ImportIcon } from "@src/assets/icons";
|
||||
|
||||
import ImportSettings from "./ImportSettings";
|
||||
@@ -50,16 +50,18 @@ export default function ActionsMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="Actions…"
|
||||
id="table-settings-actions-button"
|
||||
aria-controls="table-settings-actions-menu"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? "true" : undefined}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
{mode === "create" ? <ImportIcon /> : <ExportIcon />}
|
||||
</IconButton>
|
||||
<Tooltip title="Actions menu">
|
||||
<IconButton
|
||||
aria-label="Actions…"
|
||||
id="table-settings-actions-button"
|
||||
aria-controls="table-settings-actions-menu"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? "true" : undefined}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
{mode === "create" ? <ImportIcon /> : <ExportIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Menu
|
||||
id="table-settings-actions-menu"
|
||||
|
||||
@@ -3,7 +3,13 @@ import { useAtom, useSetAtom } from "jotai";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSnackbar } from "notistack";
|
||||
|
||||
import { IconButton, Menu, MenuItem, DialogContentText } from "@mui/material";
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
DialogContentText,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
||||
|
||||
import {
|
||||
@@ -55,16 +61,18 @@ export default function DeleteMenu({ clearDialog, data }: IDeleteMenuProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="Delete table…"
|
||||
id="table-settings-delete-button"
|
||||
aria-controls="table-settings-delete-menu"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? "true" : undefined}
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<Tooltip title="Delete menu">
|
||||
<IconButton
|
||||
aria-label="Delete table…"
|
||||
id="table-settings-delete-button"
|
||||
aria-controls="table-settings-delete-menu"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? "true" : undefined}
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Menu
|
||||
id="table-settings-delete-menu"
|
||||
|
||||
@@ -12,16 +12,25 @@ export interface ITableNameProps extends IShortTextComponentProps {
|
||||
|
||||
export default function TableName({ watchedField, ...props }: ITableNameProps) {
|
||||
const {
|
||||
field: { onChange },
|
||||
field: { onChange, value },
|
||||
useFormMethods: { control },
|
||||
disabled,
|
||||
} = props;
|
||||
|
||||
const watchedValue = useWatch({ control, name: watchedField } as any);
|
||||
useEffect(() => {
|
||||
if (!disabled && typeof watchedValue === "string" && !!watchedValue)
|
||||
onChange(startCase(watchedValue));
|
||||
}, [watchedValue, disabled]);
|
||||
if (!disabled) {
|
||||
const touched = control.getFieldState(props.name).isTouched;
|
||||
|
||||
if (!touched && typeof watchedValue === "string" && !!watchedValue) {
|
||||
// if table name field is not touched, and watched value is valid, set table name to watched value
|
||||
onChange(startCase(watchedValue));
|
||||
} else if (typeof value === "string") {
|
||||
// otherwise if table name is valid, set watched value to table name
|
||||
onChange(startCase(value.trim()));
|
||||
}
|
||||
}
|
||||
}, [watchedValue, disabled, onChange, value]);
|
||||
|
||||
return <ShortTextComponent {...props} />;
|
||||
}
|
||||
|
||||
@@ -411,7 +411,7 @@ export default function TableSettingsDialog() {
|
||||
},
|
||||
/*
|
||||
* TODO: Figure out where to store this settings
|
||||
|
||||
|
||||
{
|
||||
id: "function",
|
||||
title: "Cloud Function",
|
||||
|
||||
@@ -149,6 +149,7 @@ export const tableSettings = (
|
||||
// https://firebase.google.com/docs/firestore/quotas#collections_documents_and_fields
|
||||
validation: [
|
||||
["matches", /^[^\s]+$/, "Collection name cannot have spaces"],
|
||||
["matches", /^[^.]+$/, "Collection name cannot have dots"],
|
||||
["notOneOf", [".", ".."], "Collection name cannot be . or .."],
|
||||
[
|
||||
"test",
|
||||
@@ -194,6 +195,7 @@ export const tableSettings = (
|
||||
// https://firebase.google.com/docs/firestore/quotas#collections_documents_and_fields
|
||||
validation: [
|
||||
["matches", /^[^\s]+$/, "Collection name cannot have spaces"],
|
||||
["matches", /^[^.]+$/, "Collection name cannot have dots"],
|
||||
["notOneOf", [".", ".."], "Collection name cannot be . or .."],
|
||||
[
|
||||
"test",
|
||||
@@ -211,7 +213,7 @@ export const tableSettings = (
|
||||
name: "name",
|
||||
label: "Table name",
|
||||
required: true,
|
||||
watchedField: "collection",
|
||||
watchedField: "name",
|
||||
assistiveText: "User-facing name for this table",
|
||||
autoFocus: true,
|
||||
gridCols: { xs: 12, sm: 6 },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MenuItem,
|
||||
ListItemText,
|
||||
Box,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
AddRow as AddRowIcon,
|
||||
@@ -16,33 +17,42 @@ import {
|
||||
ChevronDown as ArrowDropDownIcon,
|
||||
} from "@src/assets/icons";
|
||||
|
||||
import {
|
||||
projectScope,
|
||||
userRolesAtom,
|
||||
tableAddRowIdTypeAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import { projectScope, userRolesAtom } from "@src/atoms/projectScope";
|
||||
import {
|
||||
tableScope,
|
||||
tableSettingsAtom,
|
||||
tableFiltersAtom,
|
||||
tableSortsAtom,
|
||||
addRowAtom,
|
||||
_updateRowDbAtom,
|
||||
tableColumnsOrderedAtom,
|
||||
tableSchemaAtom,
|
||||
updateTableSchemaAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
import { TableIdType } from "@src/types/table";
|
||||
|
||||
export default function AddRow() {
|
||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
const [tableFilters] = useAtom(tableFiltersAtom, tableScope);
|
||||
const [tableSorts] = useAtom(tableSortsAtom, tableScope);
|
||||
const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope);
|
||||
const addRow = useSetAtom(addRowAtom, tableScope);
|
||||
const [idType, setIdType] = useAtom(tableAddRowIdTypeAtom, projectScope);
|
||||
|
||||
const anchorEl = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [openIdModal, setOpenIdModal] = useState(false);
|
||||
|
||||
const idType = tableSchema.idType || "decrement";
|
||||
const forceRandomId = tableFilters.length > 0 || tableSorts.length > 0;
|
||||
|
||||
const handleSetIdType = async (idType: TableIdType) => {
|
||||
// TODO(han): refactor atom - error handler
|
||||
await updateTableSchema!({
|
||||
idType,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (idType === "random" || (forceRandomId && idType === "decrement")) {
|
||||
addRow({
|
||||
@@ -74,42 +84,51 @@ export default function AddRow() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup
|
||||
variant="contained"
|
||||
color="primary"
|
||||
aria-label="Split button"
|
||||
ref={anchorEl}
|
||||
disabled={tableSettings.tableType === "collectionGroup" || !addRow}
|
||||
<Tooltip
|
||||
title={
|
||||
tableSettings.tableType === "collectionGroup"
|
||||
? "Add row is not supported for collection group."
|
||||
: null
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Button
|
||||
<ButtonGroup
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleClick}
|
||||
startIcon={
|
||||
idType === "decrement" && !forceRandomId ? (
|
||||
<AddRowTopIcon />
|
||||
) : (
|
||||
<AddRowIcon />
|
||||
)
|
||||
}
|
||||
aria-label="Split button"
|
||||
ref={anchorEl}
|
||||
disabled={tableSettings.tableType === "collectionGroup" || !addRow}
|
||||
>
|
||||
Add row{idType === "custom" ? "…" : ""}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleClick}
|
||||
startIcon={
|
||||
idType === "decrement" && !forceRandomId ? (
|
||||
<AddRowTopIcon />
|
||||
) : (
|
||||
<AddRowIcon />
|
||||
)
|
||||
}
|
||||
>
|
||||
Add row{idType === "custom" ? "…" : ""}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
aria-label="Select row add position"
|
||||
aria-haspopup="menu"
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => setOpen(true)}
|
||||
id="add-row-menu-button"
|
||||
aria-controls={open ? "add-row-menu" : undefined}
|
||||
aria-expanded={open ? "true" : "false"}
|
||||
>
|
||||
<ArrowDropDownIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
aria-label="Select row add position"
|
||||
aria-haspopup="menu"
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => setOpen(true)}
|
||||
id="add-row-menu-button"
|
||||
aria-controls={open ? "add-row-menu" : undefined}
|
||||
aria-expanded={open ? "true" : "false"}
|
||||
>
|
||||
<ArrowDropDownIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Tooltip>
|
||||
|
||||
<Select
|
||||
id="add-row-menu"
|
||||
@@ -118,7 +137,7 @@ export default function AddRow() {
|
||||
label="Row add position"
|
||||
style={{ display: "none" }}
|
||||
value={forceRandomId && idType === "decrement" ? "random" : idType}
|
||||
onChange={(e) => setIdType(e.target.value as typeof idType)}
|
||||
onChange={(e) => handleSetIdType(e.target.value as typeof idType)}
|
||||
MenuProps={{
|
||||
anchorEl: anchorEl.current,
|
||||
MenuListProps: { "aria-labelledby": "add-row-menu-button" },
|
||||
@@ -207,3 +226,100 @@ export default function AddRow() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddRowArraySubTable() {
|
||||
const [updateRowDb] = useAtom(_updateRowDbAtom, tableScope);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const anchorEl = useRef<HTMLDivElement>(null);
|
||||
const [addRowAt, setAddNewRowAt] = useState<"top" | "bottom">("bottom");
|
||||
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
|
||||
|
||||
if (!updateRowDb) return null;
|
||||
|
||||
const handleClick = () => {
|
||||
const initialValues: Record<string, any> = {};
|
||||
|
||||
// Set initial values based on default values
|
||||
for (const column of tableColumnsOrdered) {
|
||||
if (column.config?.defaultValue?.type === "static")
|
||||
initialValues[column.key] = column.config.defaultValue.value!;
|
||||
else if (column.config?.defaultValue?.type === "null")
|
||||
initialValues[column.key] = null;
|
||||
}
|
||||
|
||||
updateRowDb("", initialValues, undefined, {
|
||||
index: 0,
|
||||
operation: {
|
||||
addRow: addRowAt,
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup
|
||||
variant="contained"
|
||||
color="primary"
|
||||
aria-label="Split button"
|
||||
ref={anchorEl}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleClick}
|
||||
startIcon={addRowAt === "top" ? <AddRowTopIcon /> : <AddRowIcon />}
|
||||
>
|
||||
Add row to {addRowAt}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
aria-label="Select row add position"
|
||||
aria-haspopup="menu"
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => setOpen(true)}
|
||||
id="add-row-menu-button"
|
||||
aria-controls={open ? "add-row-menu" : undefined}
|
||||
aria-expanded={open ? "true" : "false"}
|
||||
>
|
||||
<ArrowDropDownIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<Select
|
||||
id="add-row-menu"
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
label="Row add position"
|
||||
style={{ display: "none" }}
|
||||
value={addRowAt}
|
||||
onChange={(e) => setAddNewRowAt(e.target.value as typeof addRowAt)}
|
||||
MenuProps={{
|
||||
anchorEl: anchorEl.current,
|
||||
MenuListProps: { "aria-labelledby": "add-row-menu-button" },
|
||||
anchorOrigin: { horizontal: "left", vertical: "bottom" },
|
||||
transformOrigin: { horizontal: "left", vertical: "top" },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="top">
|
||||
<ListItemText
|
||||
primary="To top"
|
||||
secondary="Adds a new row to the top of this table"
|
||||
secondaryTypographyProps={{ variant: "caption" }}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem value="bottom">
|
||||
<ListItemText
|
||||
primary="To bottom"
|
||||
secondary={"Adds a new row to the bottom of this table"}
|
||||
secondaryTypographyProps={{
|
||||
variant: "caption",
|
||||
whiteSpace: "pre-line",
|
||||
}}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user