Merge branch 'develop' into feature/code-editor-package-typing

# Conflicts:
#	src/components/CodeEditor/useMonacoCustomizations.ts
#	yarn.lock
This commit is contained in:
Bobby Wang
2023-08-29 20:19:27 +08:00
201 changed files with 8043 additions and 6677 deletions

View File

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

View File

@@ -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
View File

@@ -7,6 +7,7 @@
# production
/build
/dist
cloud_functions/functions/lib
# firebase

View File

@@ -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/projects/3) 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://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.
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://discord.com/invite/fjBugmvzZP). 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

View File

@@ -1,25 +1,24 @@
<p align="center">
<img src="src/assets/logo-sticker.svg" alt="Rowy" height="69" />
</p>
<a href="https://www.rowy.io/" target="_blank">
<img width="100%" src="https://user-images.githubusercontent.com/307298/218350866-cfd7c011-2247-4074-8b1d-06c26a4d0b96.png" />
</a>
<h3 align="center">
✨ Low-code backend ✨ <br/>
</h3>
<h4 align="center">
Manage your database and build automations as easy as using a spreadsheet.
✨ Airtable-like UI for managing database ✨ Build any automation, with or without code ✨
</h4>
<p align="center" >
Connect to your database (Firestore), manage data on an Airtable-like UI with role based access controls. Build cloud function workflows in JS/TS using any NPM or APIs. Forget CLIs, configs, and DevOps. Focus on building your apps with a platform designed for developer productivity. Low-code for Firebase and Google Cloud.
Connect to your database and create Cloud Functions in low-code - without leaving your browser.<br/>
Focus on building your apps.
Low-code for Firebase and Google Cloud.
</p>
<div align="center">
[![Discord](https://img.shields.io/discord/853498675484819476?color=%234200FF&label=Chat%20with%20us&logo=discord&logoColor=%23FFFFFF&style=for-the-badge)](https://discord.gg/fjBugmvzZP)
[![Rowy Discord](https://dcbadge.vercel.app/api/server/fjBugmvzZP)](https://discord.gg/fjBugmvzZP)
<p align="center">
<a href="http://www.rowy.io"><b>Website</b></a> •
<a href="http://docs.rowy.io"><b>Documentation</b></a> •
<a href="https://discord.gg/fjBugmvzZP"><b>Discord</b></a> •
<a href="https://discord.gg/fjBugmvzZP"><b>Chat with us</b></a> •
<a href="https://twitter.com/rowyio"><b>Twitter</b></a>
</p>
@@ -27,26 +26,35 @@ Connect to your database (Firestore), manage data on an Airtable-like UI with ro
[![GitHub stars](https://img.shields.io/github/stars/rowyio/rowy)](https://github.com/rowyio/rowy/stargazers/)
</div>
<img width="100%" src="https://user-images.githubusercontent.com/307298/157184506-f94f3f5b-e6d3-49df-9a2c-f665511883f2.png" />
## Live Demo
## Live Demo 🛝
💥 Check out the [live demo](https://demo.rowy.io/) of Rowy 💥
💥 Explore Rowy on [live demo playground](https://demo.rowy.io/) 💥
## Quick Deploy
## Features ✨
Set up Rowy on your Google Cloud Platform project with this easy deploy button.
[<img width="250" alt="Guided quick start button" src="https://user-images.githubusercontent.com/307298/185548050-e9208fb6-fe53-4c84-bbfa-53c08e03c15f.png">](https://rowy.app/)
https://rowy.app
## Documentation
You can find the full documentation with how-to guides and templates
[here](http://docs.rowy.io/).
## Features
<!-- <table>
<tr>
<th>
<a href="#">Database</a>
</th>
<th>
<a href="#">Automation</a>
</th>
</tr>
<tr>
<td>
<a href="#">
<img src=""/>
</a>
</td>
<td>
<a href="#">
<img src=""/>
</a>
</td>
</tr>
</table> -->
https://user-images.githubusercontent.com/307298/157185793-f67511cd-7b7b-4229-9589-d7defbf7a63f.mp4
@@ -89,27 +97,35 @@ https://user-images.githubusercontent.com/307298/157185793-f67511cd-7b7b-4229-95
- Built in user management
- Customizable views for different user roles
## Install
## Quick guided install
Set up Rowy on your Google Cloud project with this one-click deploy button. Your
data and cloud functions stay on your own Firestore/GCP.
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.
[![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://rowy.app/)
[<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/)
The one-click deploy makes the process of setting up easy with a step by step
guide and ensures your project is setup correctly.
https://rowy.app
It deploys [Rowy Run](https://github.com/rowyio/rowyrun), an open-source Cloud
Run instance that operates exclusively on your GCP project. So we never have
access to your service account or any of your data.
## Documentation
Alternatively, you can manually install by
[following this guide](https://docs.rowy.io/setup/install).
You can find the full documentation with how-to guides and templates
[here](http://docs.rowy.io/).
## 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.
## Roadmap
[View our roadmap](https://demo.rowy.io/table/roadmap) 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=).
@@ -129,5 +145,5 @@ If you'd like to propose a feature, submit an issue
## Help
- Live chat support on [Discord](https://discord.gg/fjBugmvzZP)
- Live chat support on [Discord](https://www.rowy.io/discord)
- [Email](mailto:hello@rowy.io)

View File

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

View File

@@ -1,6 +1,6 @@
{
"hosting": {
"public": "build",
"public": "dist",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{

View File

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

View File

@@ -10,13 +10,14 @@
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@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 +25,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,7 +35,6 @@
"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",
@@ -47,7 +48,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 +62,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 +155,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 +167,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 +178,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 +186,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"

View File

@@ -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" */));
@@ -50,8 +51,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
@@ -134,6 +133,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 +167,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 />}

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

View File

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

View File

@@ -147,10 +147,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",

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import Editor, { EditorProps } from "@monaco-editor/react";
import Editor, { EditorProps, Monaco } from "@monaco-editor/react";
import type { editor } from "monaco-editor/esm/vs/editor/editor.api";
import { useTheme, Box, BoxProps, AppBar, Toolbar } from "@mui/material";
@@ -73,6 +73,36 @@ export default function CodeEditor({
onValidate?.(markers);
};
const validate = (monaco: Monaco, model: editor.ITextModel) => {
const markers = [];
for (let i = 1; i < model.getLineCount() + 1; i++) {
const range = {
startLineNumber: i,
startColumn: 1,
endLineNumber: i,
endColumn: model.getLineLength(i) + 1,
};
const line = model.getValueInRange(range);
for (const keyword of ["console.log", "console.warn", "console.error"]) {
const consoleLogIndex = line.indexOf(keyword);
if (consoleLogIndex >= 0) {
markers.push({
message: `Replace with ${keyword.replace(
"console",
"logging"
)}: Rowy Cloud Logging provides a better experience to view logs. Simply replace 'console' with 'logging'. \n\nhttps://docs.rowy.io/cloud-logs`,
severity: monaco.MarkerSeverity.Warning,
startLineNumber: range.startLineNumber,
endLineNumber: range.endLineNumber,
startColumn: consoleLogIndex + 1,
endColumn: consoleLogIndex + keyword.length + 1,
});
}
}
}
monaco.editor.setModelMarkers(model, "owner", markers);
};
return (
<TrapFocus open={fullScreen}>
<Box
@@ -95,6 +125,12 @@ export default function CodeEditor({
beforeMount={(monaco) => {
monaco.editor.defineTheme("github-light", githubLightTheme as any);
monaco.editor.defineTheme("github-dark", githubDarkTheme as any);
monaco.editor.onDidCreateModel((model) => {
validate(monaco, model);
model.onDidChangeContent(() => {
validate(monaco, model);
});
});
}}
{...props}
onMount={async (editor, monaco) => {

View File

@@ -18,9 +18,9 @@ type uploadOptions = {
fileName?: string;
};
type RowyLogging = {
log: (payload: any) => void;
warn: (payload: any) => void;
error: (payload: any) => void;
log: (...payload: any[]) => void;
warn: (...payload: any[]) => void;
error: (...payload: any[]) => void;
};
interface Rowy {
metadata: {

View File

@@ -1,10 +1,6 @@
import { useEffect } from "react";
// import {
// quicktype,
// InputData,
// jsonInputForTargetLanguage,
// } from "quicktype-core";
import { useAtom } from "jotai";
import { matchSorter } from "match-sorter";
import {
tableScope,
@@ -13,18 +9,17 @@ import {
} from "@src/atoms/tableScope";
import { useMonaco } from "@monaco-editor/react";
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 {
@@ -57,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,7 +61,6 @@ export default function useMonacoCustomizations({
};
}, []);
// Initialize external libs & TypeScript compiler options
useEffect(() => {
if (!monaco) return;
@@ -131,6 +125,106 @@ export default function useMonacoCustomizations({
}
}, [monaco, stringifiedDiagnosticsOptions]);
const setReplacementActions = () => {
if (!monaco) return;
const { dispose } = monaco.languages.registerCodeActionProvider(
"javascript",
{
provideCodeActions: (model, range, context, token) => {
const consoleLogReplacements = context.markers
.filter((error) => {
return error.message.includes("Rowy Cloud Logging");
})
.map((error) => {
// first sentence of the message is "Replace with logging.[log/warn/error]"
const firstSentence = error.message.split(":")[0];
const replacement = firstSentence.split("with ")[1];
return {
title: firstSentence,
diagnostics: [error],
kind: "quickfix",
edit: {
edits: [
{
resource: model.uri,
edit: {
range: error,
text: replacement,
},
},
],
},
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: [...consoleLogReplacements, ...secretNameReplacements],
dispose: () => {},
};
},
}
);
monaco.editor.onWillDisposeModel((model) => {
// dispose code action provider when model is disposed
// this makes sure code actions are not displayed multiple times
dispose();
});
};
const addJsonFieldDefinition = async (
columnKey: string,
interfaceName: string
@@ -165,26 +259,7 @@ export default function useMonacoCustomizations({
//}
};
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.typescriptDefaults.addExtraLib(secretsDef);
} catch (error) {
console.error("Could not set secret definitions: ", error);
}
};
//TODO: types
const setBaseDefinitions = () => {
const rowDefinition =
tableColumnsOrdered
@@ -233,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,

View File

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

View File

@@ -64,6 +64,7 @@ import {
} from "@src/utils/table";
import { runRoutes } from "@src/constants/runRoutes";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import useSaveTableSorts from "@src/components/Table/ColumnHeader/useSaveTableSorts";
export interface IMenuModalProps {
name: string;
@@ -116,6 +117,8 @@ export default function ColumnMenu({
const [altPress] = useAtom(altPressAtom, projectScope);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const triggerSaveTableSorts = useSaveTableSorts(canEditColumns);
if (!columnMenu) return null;
const { column, anchorEl } = columnMenu;
if (column.type === FieldType.last) return null;
@@ -189,6 +192,10 @@ export default function ColumnMenu({
setTableSorts(
isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }]
);
triggerSaveTableSorts(
isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }]
);
handleClose();
},
active: isSorted && !isAsc,
@@ -203,6 +210,9 @@ export default function ColumnMenu({
setTableSorts(
isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }]
);
triggerSaveTableSorts(
isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }]
);
handleClose();
},
active: isSorted && isAsc,
@@ -409,7 +419,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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useAtom } from "jotai";
import { useParams, Link as RouterLink } from "react-router-dom";
import { find, camelCase, uniq } from "lodash-es";
import { find, camelCase } from "lodash-es";
import {
Stack,
@@ -12,13 +12,9 @@ import {
} from "@mui/material";
import ReadOnlyIcon from "@mui/icons-material/EditOffOutlined";
import InfoTooltip from "@src/components/InfoTooltip";
import RenderedMarkdown from "@src/components/RenderedMarkdown";
import {
projectScope,
userRolesAtom,
tableDescriptionDismissedAtom,
tablesAtom,
} from "@src/atoms/projectScope";
import { ROUTES } from "@src/constants/routes";
@@ -31,10 +27,6 @@ export default function BreadcrumbsTableRoot(props: StackProps) {
const { id } = useParams();
const [userRoles] = useAtom(userRolesAtom, projectScope);
const [dismissed, setDismissed] = useAtom(
tableDescriptionDismissedAtom,
projectScope
);
const [tables] = useAtom(tablesAtom, projectScope);
const tableSettings = find(tables, ["id", id]);
@@ -83,28 +75,6 @@ export default function BreadcrumbsTableRoot(props: StackProps) {
<ReadOnlyIcon fontSize="small" sx={{ ml: 0.5 }} color="action" />
</Tooltip>
)}
{tableSettings.description && (
<InfoTooltip
description={
<div>
<RenderedMarkdown
children={tableSettings.description}
restrictionPreset="singleLine"
/>
</div>
}
buttonLabel="Table info"
tooltipProps={{
componentsProps: {
popper: { sx: { zIndex: "appBar" } },
tooltip: { sx: { maxWidth: "75vw" } },
} as any,
}}
defaultOpen={!dismissed.includes(tableSettings.id)}
onClose={() => setDismissed((d) => uniq([...d, tableSettings.id]))}
/>
)}
</Stack>
);
}

View File

@@ -233,6 +233,7 @@ export const ColumnHeader = memo(function ColumnHeader({
sortKey={sortKey}
currentSort={currentSort}
tabIndex={focusInsideCell ? 0 : -1}
canEditColumns={canEditColumns}
/>
)}

View File

@@ -9,6 +9,7 @@ import IconSlash, {
} from "@src/components/IconSlash";
import { tableScope, tableSortsAtom } from "@src/atoms/tableScope";
import useSaveTableSorts from "./useSaveTableSorts";
export const SORT_STATES = ["none", "desc", "asc"] as const;
@@ -16,6 +17,7 @@ export interface IColumnHeaderSortProps {
sortKey: string;
currentSort: typeof SORT_STATES[number];
tabIndex?: number;
canEditColumns: boolean;
}
/**
@@ -26,15 +28,22 @@ export const ColumnHeaderSort = memo(function ColumnHeaderSort({
sortKey,
currentSort,
tabIndex,
canEditColumns,
}: IColumnHeaderSortProps) {
const setTableSorts = useSetAtom(tableSortsAtom, tableScope);
const nextSort =
SORT_STATES[SORT_STATES.indexOf(currentSort) + 1] ?? SORT_STATES[0];
const triggerSaveTableSorts = useSaveTableSorts(canEditColumns);
const handleSortClick = () => {
if (nextSort === "none") setTableSorts([]);
else setTableSorts([{ key: sortKey, direction: nextSort }]);
setTableSorts(
nextSort === "none" ? [] : [{ key: sortKey, direction: nextSort }]
);
triggerSaveTableSorts(
nextSort === "none" ? [] : [{ key: sortKey, direction: nextSort }]
);
};
return (

View File

@@ -0,0 +1,98 @@
import { useCallback, useState } from "react";
import { useAtom } from "jotai";
import { SnackbarKey, useSnackbar } from "notistack";
import LoadingButton from "@mui/lab/LoadingButton";
import CheckIcon from "@mui/icons-material/Check";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import {
tableIdAtom,
tableScope,
updateTableSchemaAtom,
} from "@src/atoms/tableScope";
import { 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 { enqueueSnackbar, closeSnackbar } = useSnackbar();
const [snackbarId, setSnackbarId] = useState<SnackbarKey | null>(null);
// Offer to save when table sorts changes
const trigger = useCallback(
(sorts: TableSort[]) => {
if (!updateTableSchema) throw new Error("Cannot update table schema");
if (updateUserSettings) {
updateUserSettings({
tables: {
[`${tableId}`]: { sorts },
},
});
}
if (!canEditColumns) return;
if (snackbarId) {
closeSnackbar(snackbarId);
}
setSnackbarId(
enqueueSnackbar("Apply this sorting for all users?", {
action: (
<SaveTableSortButton
updateTable={async () => await updateTableSchema({ sorts })}
/>
),
anchorOrigin: { horizontal: "center", vertical: "top" },
})
);
return () => (snackbarId ? closeSnackbar(snackbarId) : null);
},
[
updateUserSettings,
canEditColumns,
snackbarId,
enqueueSnackbar,
tableId,
closeSnackbar,
updateTableSchema,
]
);
return trigger;
}
function SaveTableSortButton({ updateTable }: { updateTable: Function }) {
const [state, setState] = useState<"" | "loading" | "success" | "error">("");
const handleSaveToSchema = async () => {
setState("loading");
try {
await updateTable();
setState("success");
} catch (e) {
setState("error");
}
};
return (
<LoadingButton
variant="contained"
color="primary"
onClick={handleSaveToSchema}
loading={Boolean(state)}
loadingIndicator={
state === "success" ? (
<CheckIcon color="primary" />
) : (
<CircularProgressOptical size={20} color="primary" />
)
}
>
Save
</LoadingButton>
);
}
export default useSaveTableSorts;

View File

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

View File

@@ -21,8 +21,8 @@ import {
projectIdAtom,
userRolesAtom,
altPressAtom,
tableAddRowIdTypeAtom,
confirmDialogAtom,
updateUserSettingsAtom,
} from "@src/atoms/projectScope";
import {
tableScope,
@@ -34,8 +34,11 @@ import {
deleteRowAtom,
updateFieldAtom,
tableFiltersPopoverAtom,
_updateRowDbAtom,
tableIdAtom,
} from "@src/atoms/tableScope";
import { FieldType } from "@src/constants/fields";
import { TableRow } from "@src/types/table";
interface IMenuContentsProps {
onClose: () => void;
@@ -45,7 +48,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 +56,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 cells value cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: clearValue,
});
}
};
const rowActions: IContextMenuItem[] = [
{
label: "Copy ID",
@@ -112,51 +211,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 +246,47 @@ 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,
},
});
];
if (updateUserSettings) {
updateUserSettings({ tables: { [`${tableId}`]: { filters } } });
}
onClose();
};
const cellActions = [
@@ -218,24 +299,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 cells 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);

View File

@@ -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="Youre offline"
description="Go online to view this tables 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="Youre offline"
description="Go online to view this tables data"
style={{ height: `calc(100vh - ${TOP_BAR_HEIGHT}px)` }}
/>
);
}
}

View File

@@ -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={{

View File

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

View File

@@ -19,6 +19,17 @@ export const StyledCell = styled("div")(({ theme }) => ({
alignItems: "center",
},
"& > .cell-contents-contain-none": {
padding: "0 var(--cell-padding)",
width: "100%",
height: "100%",
contain: "none",
overflow: "hidden",
display: "flex",
alignItems: "center",
},
backgroundColor: "var(--cell-background-color)",
border: `1px solid ${theme.palette.divider}`,

View File

@@ -1,5 +1,5 @@
import { useMemo, useRef, useState, useEffect, useCallback } from "react";
import useStateRef from "react-usestateref";
// import useStateRef from "react-usestateref"; // testing with useStateWithRef
import { useAtom, useSetAtom } from "jotai";
import { useThrottledCallback } from "use-debounce";
import {
@@ -31,13 +31,17 @@ import {
tablePageAtom,
updateColumnAtom,
selectedCellAtom,
tableSortsAtom,
tableIdAtom,
} from "@src/atoms/tableScope";
import { projectScope, userSettingsAtom } from "@src/atoms/projectScope";
import { getFieldType, getFieldProp } from "@src/components/fields";
import { useKeyboardNavigation } from "./useKeyboardNavigation";
import { useMenuAction } from "./useMenuAction";
import { useSaveColumnSizing } from "./useSaveColumnSizing";
import useHotKeys from "./useHotKey";
import type { TableRow, ColumnConfig } from "@src/types/table";
import useStateWithRef from "./useStateWithRef"; // testing with useStateWithRef
export const DEFAULT_ROW_HEIGHT = 41;
export const DEFAULT_COL_WIDTH = 150;
@@ -98,11 +102,18 @@ export default function Table({
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
// Get user settings and tableId for applying sort sorting
const [userSettings] = useAtom(userSettingsAtom, projectScope);
const [tableId] = useAtom(tableIdAtom, tableScope);
const setTableSorts = useSetAtom(tableSortsAtom, tableScope);
// Store a **state** and reference to the container element
// so the state can re-render `TableBody`, preventing virtualization
// not detecting scroll if the container element was initially `null`
const [containerEl, setContainerEl, containerRef] =
useStateRef<HTMLDivElement | null>(null);
// useStateRef<HTMLDivElement | null>(null); // <-- older approach with useStateRef
useStateWithRef<HTMLDivElement | null>(null); // <-- newer approach with custom hook
const gridRef = useRef<HTMLDivElement>(null);
// Get column defs from table schema
@@ -200,8 +211,6 @@ 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,
@@ -233,9 +242,24 @@ export default function Table({
containerRef,
]);
// apply user default sort on first render
const [applySort, setApplySort] = useState(true);
useEffect(() => {
if (applySort && Object.keys(tableSchema).length) {
const userDefaultSort = userSettings.tables?.[tableId]?.sorts || [];
setTableSorts(
userDefaultSort.length ? userDefaultSort : tableSchema.sorts || []
);
setApplySort(false);
}
}, [tableSchema, userSettings, tableId, setTableSorts, applySort]);
return (
<div
ref={(el) => setContainerEl(el)}
ref={(el) => {
if (!el) return;
setContainerEl(el);
}}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
style={{ overflow: "auto", width: "100%", height: "100%" }}
>

View File

@@ -83,7 +83,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 +102,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",

View File

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

View File

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

View File

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

View File

@@ -192,7 +192,7 @@ export default function withRenderTableCell(
if (editorMode === "inline") {
return (
<div
className="cell-contents"
className="cell-contents-contain-none"
style={options.disablePadding ? { padding: 0 } : undefined}
ref={displayCellRef}
>

View File

@@ -46,8 +46,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}

View File

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

View File

@@ -15,16 +15,73 @@ 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,
]);
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,15 +92,15 @@ 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 !== "") {
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("");
@@ -56,21 +113,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" });
@@ -92,7 +158,7 @@ export function useMenuAction(
try {
text = await navigator.clipboard.readText();
} catch (e) {
enqueueSnackbar(`Read clilboard permission denied.`, {
enqueueSnackbar(`Read clipboard permission denied.`, {
variant: "error",
});
return;
@@ -107,14 +173,40 @@ export function useMenuAction(
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,
},
});
} catch (error) {
enqueueSnackbar(
@@ -130,32 +222,131 @@ export function useMenuAction(
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 () {
if (SUPPORTED_TYPES_PASTE.has(fieldType)) {
return func();
} 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,
};
}

View File

@@ -0,0 +1,29 @@
import {
MutableRefObject,
useCallback,
useRef,
useSyncExternalStore,
} from "react";
// NOTE: This is not the final solution. But is a potential solution for this problem.
export default function useStateWithRef<T>(
initialState: T
): [T, (newValue: T) => void, MutableRefObject<T>] {
const value = useRef<T>(initialState);
const get = useCallback(() => value.current, []);
const subscribers = useRef(new Set<() => void>());
const set = useCallback((newValue: T) => {
value.current = newValue;
subscribers.current.forEach((callback) => callback());
}, []);
const subscribe = useCallback((callback: () => void) => {
subscribers.current.add(callback);
return () => subscribers.current.delete(callback);
}, []);
const state = useSyncExternalStore(subscribe, get);
return [state, set, value];
}

View File

@@ -0,0 +1,22 @@
import { useEffect, useRef } from "react";
// This hook is used to log changes to props in a component.
export default function useTraceUpdates(
props: { [key: string]: any },
printMessage: string = "Changed props:"
) {
const prev = useRef(props);
useEffect(() => {
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
if (prev.current[k] !== v) {
// @ts-ignore
ps[k] = [prev.current[k], v];
}
return ps;
}, {});
if (Object.keys(changedProps).length > 0) {
console.log(printMessage, changedProps);
}
prev.current = props;
});
}

View File

@@ -150,6 +150,7 @@ export default function BuildLogsSnack({
timeRange: { type: "days", value: 7 },
buildLogExpanded: 0,
});
setExpanded(false);
}}
style={{ color: "white" }}
>

View File

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

View File

@@ -62,9 +62,12 @@ export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"];
const extensionBodyTemplate = {
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
// const vision = require('@google-cloud/vision');
// Task Extension is very flexible, you can do anything.
// From updating other documents in your database, to making an api request to 3rd party service.
// Example: post notification to different discord channels based on row data
@@ -87,10 +90,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 ({
@@ -98,20 +101,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 ({
@@ -120,48 +123,54 @@ 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
// const lodash = require('lodash');
return ({
channels: [], // a list of slack channel IDs in string
blocks: [], // the blocks parameter to pass in to slack api
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
// const lodash = require('lodash');
return ({
from: "Name<example@domain.com>", // send from field
personalizations: [
@@ -178,24 +187,30 @@ 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
// const lodash = require('lodash');
return ({
body: "",
url: "",
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
// const lodash = require('lodash');
// Setup twilio secret key: https://docs.rowy.io/extensions/twilio-message#secret-manager-setup
// Add any code here to customize your message or dynamically get the from/to numbers
return ({
@@ -203,12 +218,15 @@ 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
// const lodash = require('lodash');
// You can use FCM token from the row or from the user document in the database
// const FCMtoken = row.FCMtoken
// Or push through topic
@@ -241,7 +259,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
}`,
};
@@ -258,11 +276,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,
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,8 +251,8 @@ 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 });

View 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,
};
}

View File

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

View File

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

View File

@@ -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 =
@@ -37,12 +44,24 @@ export const REGEX_URL =
export const REGEX_HTML = /<\/?[a-z][\s\S]*>/;
const inferTypeFromValue = (value: any) => {
// by default the type of value is string, so trying to convert it to JSON/Object.
try {
value = JSON.parse(value);
} catch (e) {}
if (!value || typeof value === "function") return;
if (Array.isArray(value) && typeof value[0] === "string")
return FieldType.multiSelect;
if (typeof value === "boolean") return FieldType.checkbox;
if (isDate(value)) return FieldType.dateTime;
// trying to convert the value to date
if (typeof value !== "number" && +new Date(value)) {
// date and time are separated by a blank space, checking if time present.
if (value.split(" ").length > 1) {
return FieldType.dateTime;
}
return FieldType.date;
}
if (typeof value === "object") {
if ("hex" in value && "rgb" in value) return FieldType.color;
@@ -71,6 +90,7 @@ const inferTypeFromValue = (value: any) => {
export const suggestType = (data: { [key: string]: any }[], field: string) => {
const results: Record<string, number> = {};
// console.log(data)
data.forEach((row) => {
const result = inferTypeFromValue(row[field]);
if (!result) return;

View File

@@ -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,6 +65,9 @@ export const webhookBasic = {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("basicParser started")
// 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
const {body} = req;
@@ -98,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" }} />

View File

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

View File

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

View File

@@ -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,6 +14,9 @@ export const webhookSendgrid = {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("sendgridParser started")
// Import NPM package needed, some packages may not work in Webhooks
// const {default: lodash} = await import("lodash");
const { body } = req
const eventHandler = async (sgEvent) => {
// Event handlers can be modiefed to preform different actions based on the sendgrid event
@@ -48,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>

View File

@@ -1,14 +1,18 @@
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,
} 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,6 +25,9 @@ export const webhookStripe = {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("stripeParser started")
// Import NPM package needed, some packages may not work in Webhooks
// const {default: lodash} = await import("lodash");
const event = req.body
switch (event.type) {
case "payment_intent.succeeded":
@@ -46,11 +53,10 @@ 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);
return (
<>
<Typography gutterBottom>
@@ -74,8 +80,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
@@ -83,34 +90,55 @@ 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={() => {
const secretManagerLink = `https://console.cloud.google.com/security/secret-manager/create`;
window?.open?.(secretManagerLink, "_blank")?.focus();
}}
>
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"

View File

@@ -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,6 +14,9 @@ export const webhookTypeform = {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("typeformParser started")
// 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}
// ⚠️ Ensure that you have assigned ref values of the fields
@@ -80,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>

View File

@@ -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,6 +15,9 @@ export const webhook = {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("formParser started")
// 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
const {body} = req;
@@ -48,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>

View File

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

View File

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

View File

@@ -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(value.trim());
}
}
}, [watchedValue, disabled, onChange, value]);
return <ShortTextComponent {...props} />;
}

View File

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

View File

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

View File

@@ -1,7 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useState, useEffect } from "react";
import { useAtom } from "jotai";
import useMemoValue from "use-memo-value";
import { isEmpty, isDate } from "lodash-es";
import { useSearchParams } from "react-router-dom";
import { useSnackbar } from "notistack";
import {
Tab,
@@ -19,6 +22,7 @@ import TabPanel from "@mui/lab/TabPanel";
import FiltersPopover from "./FiltersPopover";
import FilterInputs from "./FilterInputs";
import { changePageUrl, separateOperands } from "./utils";
import {
projectScope,
@@ -62,12 +66,17 @@ export default function Filters() {
const [, setTableSorts] = useAtom(tableSortsAtom, tableScope);
const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope);
const [{ defaultQuery }] = useAtom(tableFiltersPopoverAtom, tableScope);
const tableFilterInputs = useFilterInputs(tableColumnsOrdered);
const setTableQuery = tableFilterInputs.setQuery;
const userFilterInputs = useFilterInputs(tableColumnsOrdered, defaultQuery);
const setUserQuery = userFilterInputs.setQuery;
const { availableFilters } = userFilterInputs;
const { availableFilters, filterColumns } = userFilterInputs;
const [searchParams] = useSearchParams();
const { enqueueSnackbar } = useSnackbar();
useEffect(() => {
let isFiltered = searchParams.get("filter");
if (isFiltered) updateUserFilter(isFiltered);
}, [searchParams]);
// Get table filters & user filters from config documents
const tableFilters = useMemoValue(
@@ -82,6 +91,44 @@ export default function Filters() {
const hasTableFilters =
Array.isArray(tableFilters) && tableFilters.length > 0;
const hasUserFilters = Array.isArray(userFilters) && userFilters.length > 0;
function updateUserFilter(str: string) {
let { operators, operands = [] } = separateOperands(str);
if (!operators.length) return;
if (operators.length) {
let appliedFilter: TableFilter[] = [];
appliedFilter = [
{
key: operands[0],
operator: operators[0],
value: Number(operands[1]),
},
];
let isValidFilter = checkFilterValidation(appliedFilter[0]);
if (isValidFilter) {
setOverrideTableFilters(true);
setUserFilters(appliedFilter);
} else {
enqueueSnackbar("Oops, Invalid filter!!!", { variant: "error" });
setUserFilters([]);
setOverrideTableFilters(false);
userFilterInputs.resetQuery();
}
}
}
function checkFilterValidation(filter: TableFilter): boolean {
let isFilterableColumn = filterColumns?.filter(
(item) =>
item.key === filter.key ||
item.label === filter.key ||
item.type === filter.key
);
if (!isFilterableColumn?.length) return false;
filter.key = isFilterableColumn?.[0]?.value;
filter.operator = filter.operator === "-is-" ? "id-equal" : filter.operator;
filter.value =
filter.operator === "id-equal" ? filter.value.toString() : filter.value;
return true;
}
// Set the local table filter
useEffect(() => {
@@ -109,16 +156,17 @@ export default function Filters() {
} else if (hasUserFilters) {
filtersToApply = userFilters;
}
updatePageURL(filtersToApply);
setLocalFilters(filtersToApply);
// Reset order so we dont have to make a new index
setTableSorts([]);
if (filtersToApply.length) {
setTableSorts([]);
}
}, [
hasTableFilters,
hasUserFilters,
setLocalFilters,
setTableSorts,
setTableQuery,
tableFilters,
tableFiltersOverridable,
setUserQuery,
@@ -171,7 +219,21 @@ export default function Filters() {
if (updateUserSettings && filters)
updateUserSettings({ tables: { [`${tableId}`]: { filters } } });
};
function updatePageURL(filters: TableFilter[]) {
if (!filters.length) {
changePageUrl();
} else {
const [filter] = filters;
const fieldName = filter.key === "_rowy_ref.id" ? "ID" : filter.key;
const operator =
filter.operator === "id-equal" ? "-is-" : filter.operator;
const formattedValue = availableFilters?.valueFormatter
? availableFilters.valueFormatter(filter.value, filter.operator)
: filter.value.toString();
const queryParams = `?filter=${fieldName}${operator}${formattedValue}`;
changePageUrl(queryParams);
}
}
return (
<FiltersPopover
appliedFilters={appliedFilters}

View File

@@ -0,0 +1,27 @@
export const URL =
window.location.protocol +
"//" +
window.location.host +
window.location.pathname;
export function separateOperands(str: string): {
operators: any[];
operands: string[];
} {
const operators = findOperators(str);
const operands = str.split(
new RegExp(operators.map((op) => `\\${op}`).join("|"), "g")
);
return { operators, operands };
}
export function changePageUrl(newURL: string | undefined = URL) {
if (newURL !== URL) {
newURL = URL + newURL;
}
window.history.pushState({ path: newURL }, "", newURL);
}
function findOperators(str: string) {
const operators = [">=", "<=", ">", "<", "==", "!=", "=", "-is-"];
const regex = new RegExp(operators.map((op) => `\\${op}`).join("|"), "g");
return str.match(regex) || [];
}

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useAtom, useSetAtom } from "jotai";
import { parse } from "csv-parse/browser/esm";
import { parse as parseJSON } from "json2csv";
import { Parser, ParserOptions } from "@json2csv/plainjs";
import { useDropzone } from "react-dropzone";
import { useDebouncedCallback } from "use-debounce";
import { useSnackbar } from "notistack";
@@ -62,10 +62,24 @@ function convertJSONToCSV(rawData: string): string | false {
return false;
}
const fields = extractFields(rawDataJSONified);
const opts = { fields };
const opts = {
fields,
transforms: [
(value: any) => {
// if the value is an array, join it with a comma
for (let key in value) {
if (Array.isArray(value[key])) {
value[key] = value[key].join(",");
}
}
return value;
},
],
};
try {
const csv = parseJSON(rawDataJSONified, opts);
const parser = new Parser(opts as ParserOptions);
const csv = parser.parse(rawDataJSONified);
return csv;
} catch (err) {
return false;
@@ -119,20 +133,6 @@ export default function ImportFromFile() {
};
}, [setImportCsv]);
const parseFile = useCallback((rawData: string) => {
if (importTypeRef.current === "json") {
if (!hasProperJsonStructure(rawData)) {
return setError("Invalid Structure! It must be an Array");
}
const converted = convertJSONToCSV(rawData);
if (!converted) {
return setError("No columns detected");
}
rawData = converted;
}
parseCsv(rawData);
}, []);
const parseCsv = useCallback(
(csvString: string) =>
parse(csvString, { delimiter: [",", "\t"] }, (err, rows) => {
@@ -149,7 +149,7 @@ export default function ImportFromFile() {
{}
)
);
console.log(mappedRows);
// console.log(mappedRows);
setImportCsv({
importType: importTypeRef.current,
csvData: { columns, rows: mappedRows },
@@ -161,6 +161,23 @@ export default function ImportFromFile() {
[setImportCsv]
);
const parseFile = useCallback(
(rawData: string) => {
if (importTypeRef.current === "json") {
if (!hasProperJsonStructure(rawData)) {
return setError("Invalid Structure! It must be an Array");
}
const converted = convertJSONToCSV(rawData);
if (!converted) {
return setError("No columns detected");
}
rawData = converted;
}
parseCsv(rawData);
},
[parseCsv]
);
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
try {

View File

@@ -1,6 +1,5 @@
import { Suspense, forwardRef } from "react";
import { useAtom } from "jotai";
import { Offline, Online } from "react-detect-offline";
import { Tooltip, Typography, TypographyProps } from "@mui/material";
import SyncIcon from "@mui/icons-material/Sync";
@@ -13,6 +12,7 @@ import {
serverDocCountAtom,
} from "@src/atoms/tableScope";
import { spreadSx } from "@src/utils/ui";
import useOffline from "@src/hooks/useOffline";
const StatusText = forwardRef(function StatusText(
props: TypographyProps,
@@ -78,22 +78,21 @@ function LoadedRowsStatus() {
}
export default function SuspendedLoadedRowsStatus() {
return (
<>
<Online>
<Suspense fallback={<StatusText>{loadingIcon}Loading</StatusText>}>
<LoadedRowsStatus />
</Suspense>
</Online>
<Offline>
<Tooltip title="Changes will be saved when you reconnect" describeChild>
<StatusText color="error.main">
<OfflineIcon />
Offline
</StatusText>
</Tooltip>
</Offline>
</>
);
const isOffline = useOffline();
if (isOffline) {
return (
<Tooltip title="Changes will be saved when you reconnect" describeChild>
<StatusText color="error.main">
<OfflineIcon />
Offline
</StatusText>
</Tooltip>
);
} else {
return (
<Suspense fallback={<StatusText>{loadingIcon}Loading</StatusText>}>
<LoadedRowsStatus />
</Suspense>
);
}
}

View File

@@ -0,0 +1,135 @@
import { useAtom } from "jotai";
import {
Grid,
IconButton,
MenuItem,
Stack,
TextField,
Typography,
alpha,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import {
tableColumnsOrderedAtom,
tableScope,
tableSettingsAtom,
tableSortsAtom,
} from "@src/atoms/tableScope";
import SortPopover from "./SortPopover";
import ColumnSelect from "@src/components/Table/ColumnSelect";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import { projectScope, userRolesAtom } from "@src/atoms/projectScope";
import useSaveTableSorts from "@src/components/Table/ColumnHeader/useSaveTableSorts";
export default function Sort() {
const [userRoles] = useAtom(userRolesAtom, projectScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const canEditColumns = Boolean(
userRoles.includes("ADMIN") ||
tableSettings.modifiableBy?.some((r) => userRoles.includes(r))
);
const [tableSorts, setTableSorts] = useAtom(tableSortsAtom, tableScope);
const triggerSaveTableSorts = useSaveTableSorts(canEditColumns);
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const sortColumns = tableColumnsOrdered.map(({ key, name, type, index }) => ({
value: key,
label: name,
type,
index,
}));
return (
<SortPopover>
{({ handleClose }) => (
<Grid container spacing={2} sx={{ p: 3 }}>
<Grid item xs={5.5}>
<ColumnSelect
multiple={false}
label="Column"
options={sortColumns}
value={tableSorts[0].key}
onChange={(value: string | null) => {
setTableSorts(
value === null
? []
: [{ key: value, direction: tableSorts[0].direction }]
);
triggerSaveTableSorts(
value === null
? []
: [{ key: value, direction: tableSorts[0].direction }]
);
}}
/>
</Grid>
<Grid item xs={5.5}>
<TextField
label="Sort"
select
variant="filled"
fullWidth
value={tableSorts[0].direction}
onChange={(e) => {
setTableSorts([
{
key: tableSorts[0].key,
direction: e.target.value === "asc" ? "asc" : "desc",
},
]);
triggerSaveTableSorts([
{
key: tableSorts[0].key,
direction: e.target.value === "asc" ? "asc" : "desc",
},
]);
}}
>
<MenuItem key="asc" value="asc">
<Stack direction="row" gap={1} alignItems="center">
<ArrowUpwardIcon />
<Typography>Sort ascending</Typography>
</Stack>
</MenuItem>
<MenuItem key="desc" value="desc">
<Stack direction="row" gap={1} alignItems="center">
<ArrowDownwardIcon />
<Typography>Sort descending</Typography>
</Stack>
</MenuItem>
</TextField>
</Grid>
<Grid item xs={1} alignSelf="flex-end">
<IconButton
size="small"
onClick={() => {
setTableSorts([]);
triggerSaveTableSorts([]);
}}
sx={{
"&:hover, &:focus": {
color: "error.main",
backgroundColor: (theme) =>
alpha(
theme.palette.error.main,
theme.palette.action.hoverOpacity * 2
),
},
}}
>
<DeleteIcon />
</IconButton>
</Grid>
</Grid>
)}
</SortPopover>
);
}

View File

@@ -0,0 +1,67 @@
import { useRef, useState } from "react";
import { useAtom } from "jotai";
import { Popover } from "@mui/material";
import ButtonWithStatus from "@src/components/ButtonWithStatus";
import { tableScope, tableSortsAtom } from "@src/atoms/tableScope";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
export interface ISortPopoverProps {
children: (props: { handleClose: () => void }) => React.ReactNode;
}
export default function SortPopover({ children }: ISortPopoverProps) {
const [tableSortPopoverState, setTableSortPopoverState] = useState(false);
const anchorEl = useRef<HTMLButtonElement>(null);
const popoverId = tableSortPopoverState ? "sort-popover" : undefined;
const handleClose = () => setTableSortPopoverState(false);
const [tableSorts] = useAtom(tableSortsAtom, tableScope);
return (
<>
<ButtonWithStatus
ref={anchorEl}
variant="outlined"
color="primary"
onClick={() => setTableSortPopoverState(true)}
active={true}
startIcon={
<ArrowDownwardIcon
sx={{
transition: (theme) =>
theme.transitions.create("transform", {
duration: theme.transitions.duration.short,
}),
transform:
tableSorts[0].direction === "asc" ? "rotate(180deg)" : "none",
}}
/>
}
aria-describedby={popoverId}
>
Sorted: {tableSorts[0].key}
</ButtonWithStatus>
<Popover
id={popoverId}
open={tableSortPopoverState}
anchorEl={anchorEl.current}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
sx={{
"& .MuiPaper-root": { width: 640 },
"& .content": { p: 3 },
}}
>
{children({ handleClose })}
</Popover>
</>
);
}

View File

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

View File

@@ -1,17 +1,19 @@
import { lazy, Suspense } from "react";
import { useAtom, useSetAtom } from "jotai";
import { Stack } from "@mui/material";
import { Button, Stack } from "@mui/material";
import WebhookIcon from "@mui/icons-material/Webhook";
import {
Export as ExportIcon,
Extension as ExtensionIcon,
CloudLogs as CloudLogsIcon,
Import as ImportIcon,
} from "@src/assets/icons";
import TableToolbarButton from "./TableToolbarButton";
import { ButtonSkeleton } from "./TableToolbarSkeleton";
import AddRow from "./AddRow";
import AddRow, { AddRowArraySubTable } from "./AddRow";
import LoadedRowsStatus from "./LoadedRowsStatus";
import TableSettings from "./TableSettings";
import HiddenFields from "./HiddenFields";
@@ -30,8 +32,14 @@ import {
tableSettingsAtom,
tableSchemaAtom,
tableModalAtom,
tableSortsAtom,
} from "@src/atoms/tableScope";
import { FieldType } from "@src/constants/fields";
import { TableToolsType } from "@src/types/table";
import FilterIcon from "@mui/icons-material/FilterList";
// prettier-ignore
const Sort = lazy(() => import("./Sort" /* webpackChunkName: "Filters" */));
// prettier-ignore
const Filters = lazy(() => import("./Filters" /* webpackChunkName: "Filters" */));
@@ -43,7 +51,11 @@ const ReExecute = lazy(() => import("./ReExecute" /* webpackChunkName: "ReExecut
export const TABLE_TOOLBAR_HEIGHT = 44;
export default function TableToolbar() {
export default function TableToolbar({
disabledTools,
}: {
disabledTools?: TableToolsType[];
}) {
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
const [userRoles] = useAtom(userRolesAtom, projectScope);
const [compatibleRowyRunVersion] = useAtom(
@@ -54,7 +66,7 @@ export default function TableToolbar() {
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const openTableModal = useSetAtom(tableModalAtom, tableScope);
const [tableSorts] = useAtom(tableSortsAtom, tableScope);
const hasDerivatives =
Object.values(tableSchema.columns ?? {}).filter(
(column) => column.type === FieldType.derivative
@@ -64,6 +76,7 @@ export default function TableToolbar() {
tableSchema.compiledExtension &&
tableSchema.compiledExtension.replace(/\W/g, "")?.length > 0;
disabledTools = disabledTools ?? [];
return (
<Stack
direction="row"
@@ -87,29 +100,64 @@ export default function TableToolbar() {
},
}}
>
<AddRow />
{tableSettings.isCollection === false ? (
<AddRowArraySubTable />
) : (
<AddRow />
)}
<div /> {/* Spacer */}
<HiddenFields />
<Suspense fallback={<ButtonSkeleton />}>
<Filters />
</Suspense>
{tableSettings.isCollection === false ? (
<Button
variant="outlined"
color="primary"
startIcon={<FilterIcon />}
disabled={true}
>
Filter
</Button>
) : (
<Suspense fallback={<ButtonSkeleton />}>
<Filters />
</Suspense>
)}
{tableSorts.length > 0 && tableSettings.isCollection !== false && (
<Suspense fallback={<ButtonSkeleton />}>
<Sort />
</Suspense>
)}
<div /> {/* Spacer */}
<LoadedRowsStatus />
<div style={{ flexGrow: 1, minWidth: 64 }} />
<RowHeight />
<div /> {/* Spacer */}
{tableSettings.tableType !== "collectionGroup" && (
{disabledTools.includes("import") ? (
<TableToolbarButton
title="Import data"
icon={<ImportIcon />}
disabled={true}
/>
) : (
tableSettings.tableType !== "collectionGroup" && (
<Suspense fallback={<ButtonSkeleton />}>
<ImportData />
</Suspense>
)
)}
{(!projectSettings.exporterRoles ||
projectSettings.exporterRoles.length === 0 ||
userRoles.some((role) =>
projectSettings.exporterRoles?.includes(role)
)) && (
<Suspense fallback={<ButtonSkeleton />}>
<ImportData />
<TableToolbarButton
title="Export/Download"
onClick={() => openTableModal("export")}
icon={<ExportIcon />}
disabled={disabledTools.includes("export")}
/>
</Suspense>
)}
<Suspense fallback={<ButtonSkeleton />}>
<TableToolbarButton
title="Export/Download"
onClick={() => openTableModal("export")}
icon={<ExportIcon />}
/>
</Suspense>
{userRoles.includes("ADMIN") && (
<>
<div /> {/* Spacer */}
@@ -123,6 +171,7 @@ export default function TableToolbar() {
}
}}
icon={<WebhookIcon />}
disabled={disabledTools.includes("webhooks")}
/>
<TableToolbarButton
title="Extensions"
@@ -131,6 +180,7 @@ export default function TableToolbar() {
else openRowyRunModal({ feature: "Extensions" });
}}
icon={<ExtensionIcon />}
disabled={disabledTools.includes("extensions")}
/>
<TableToolbarButton
title="Cloud logs"
@@ -139,6 +189,7 @@ export default function TableToolbar() {
if (projectSettings.rowyRunUrl) openTableModal("cloudLogs");
else openRowyRunModal({ feature: "Cloud logs" });
}}
disabled={disabledTools.includes("cloud_logs")}
/>
{(hasDerivatives || hasExtensions) && (
<Suspense fallback={<ButtonSkeleton />}>

View File

@@ -204,6 +204,7 @@ export default function ActionFab({
}
size="small"
sx={{
zIndex: 1,
"&:not(.MuiFab-primary):not(.MuiFab-secondary):not(.Mui-disabled)": {
bgcolor: (theme) =>
theme.palette.mode === "dark"

View File

@@ -42,8 +42,7 @@ import {
import { tableScope, tableColumnsOrderedAtom } from "@src/atoms/tableScope";
import { WIKI_LINKS } from "@src/constants/externalLinks";
/* eslint-disable import/no-webpack-loader-syntax */
import actionDefs from "!!raw-loader!./action.d.ts";
import actionDefs from "./action.d.ts?raw";
import { RUN_ACTION_TEMPLATE, UNDO_ACTION_TEMPLATE } from "./templates";
import { ROUTES } from "@src/constants/routes";
import { ISettingsProps } from "@src/components/fields/types";

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