Merge branch 'develop' into new-field-subtable-array

This commit is contained in:
Anish Roy
2023-04-21 12:24:21 +05:30
committed by GitHub
103 changed files with 3536 additions and 811 deletions

View File

@@ -22,11 +22,21 @@ 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

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,27 +26,36 @@ 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
<!-- <img width="85%" src="https://firebasestorage.googleapis.com/v0/b/rowyio.appspot.com/o/publicDemo%2FRowy%20Website%20Video%20GIF%20Small.gif?alt=media&token=3f699a8f-c1f2-4046-8ed5-e4ff66947cd8" />
@@ -89,26 +97,27 @@ 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,
[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
@@ -129,5 +138,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

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

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

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";
@@ -72,6 +72,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
@@ -94,6 +124,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);
});
});
}}
onMount={(editor) => {
if (onFocus) editor.onDidFocusEditorWidget(onFocus);

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,9 +1,4 @@
import { useEffect } from "react";
// import {
// quicktype,
// InputData,
// jsonInputForTargetLanguage,
// } from "quicktype-core";
import { useAtom } from "jotai";
import {
@@ -13,15 +8,10 @@ import {
} from "@src/atoms/tableScope";
import { useMonaco } from "@monaco-editor/react";
import type { languages } from "monaco-editor/esm/vs/editor/editor.api";
import githubLightTheme from "./github-light-default.json";
import githubDarkTheme from "./github-dark-default.json";
import { useTheme } from "@mui/material";
import type { SystemStyleObject, Theme } from "@mui/system";
// TODO:
// import { getFieldType, getFieldProp } from "@src/components/fields";
/* eslint-disable import/no-webpack-loader-syntax */
import firestoreDefs from "!!raw-loader!./firestore.d.ts";
import firebaseAuthDefs from "!!raw-loader!./firebaseAuth.d.ts";
@@ -29,8 +19,7 @@ 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 { projectScope, secretNamesAtom } from "@src/atoms/projectScope";
import { getFieldProp } from "@src/components/fields";
export interface IUseMonacoCustomizationsProps {
@@ -63,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 () => {
@@ -72,7 +61,6 @@ export default function useMonacoCustomizations({
};
}, []);
// Initialize external libs & TypeScript compiler options
useEffect(() => {
if (!monaco) return;
@@ -95,6 +83,8 @@ export default function useMonacoCustomizations({
"ts:filename/utils.d.ts"
);
monaco.languages.typescript.javascriptDefaults.addExtraLib(rowyUtilsDefs);
setLoggingReplacementActions();
} catch (error) {
console.error(
"An error occurred during initialization of Monaco: ",
@@ -135,6 +125,52 @@ export default function useMonacoCustomizations({
}
}, [monaco, stringifiedDiagnosticsOptions]);
const setLoggingReplacementActions = () => {
if (!monaco) return;
const { dispose } = monaco.languages.registerCodeActionProvider(
"javascript",
{
provideCodeActions: (model, range, context, token) => {
const actions = 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,
};
});
return {
actions: actions,
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
@@ -169,26 +205,6 @@ 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.javascriptDefaults.addExtraLib(secretsDef);
} catch (error) {
console.error("Could not set secret definitions: ", error);
}
};
//TODO: types
const setBaseDefinitions = () => {
const rowDefinition =
@@ -238,14 +254,24 @@ 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(" | ")}
enum secrets {
${secretNames.secretNames
.map((secret: string) => `${secret} = "${secret}"`)
.join("\n")}
}
`;
monaco?.languages.typescript.javascriptDefaults.addExtraLib(secretsDef);
}, [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,9 @@ export default function ColumnMenu({
setTableSorts(
isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }]
);
if (!isSorted || isAsc) {
triggerSaveTableSorts([{ key: sortKey, direction: "desc" }]);
}
handleClose();
},
active: isSorted && !isAsc,
@@ -203,6 +209,9 @@ export default function ColumnMenu({
setTableSorts(
isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }]
);
if (!isSorted || !isAsc) {
triggerSaveTableSorts([{ key: sortKey, direction: "asc" }]);
}
handleClose();
},
active: isSorted && isAsc,

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

@@ -61,15 +61,19 @@ function CodeEditor({ type, column, handleChange }: ICodeEditorProps) {
// 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

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

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

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

@@ -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,24 @@ 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 }]);
triggerSaveTableSorts([
{
key: sortKey,
direction: nextSort === "none" ? "asc" : 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

@@ -21,7 +21,6 @@ import {
projectIdAtom,
userRolesAtom,
altPressAtom,
tableAddRowIdTypeAtom,
confirmDialogAtom,
} from "@src/atoms/projectScope";
import {
@@ -46,7 +45,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);
@@ -61,6 +59,8 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
tableScope
);
const addRowIdType = tableSchema.idType || "decrement";
if (!tableSchema.columns || !selectedCell) return null;
const selectedColumn = tableSchema.columns[selectedCell.columnKey];

View File

@@ -10,7 +10,6 @@ import MenuIcon from "@mui/icons-material/MoreHoriz";
import {
projectScope,
userRolesAtom,
tableAddRowIdTypeAtom,
altPressAtom,
confirmDialogAtom,
} from "@src/atoms/projectScope";
@@ -21,22 +20,22 @@ import {
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 = () => {
@@ -66,6 +65,8 @@ export const FinalColumn = memo(function FinalColumn({
}
};
const addRowIdType = tableSchema.idType || "decrement";
const handleDuplicate = () => {
const _duplicate = () => {
if (row.original._rowy_ref.arrayTableData !== undefined) {

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

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

@@ -25,6 +25,8 @@ const SUPPORTED_TYPES = new Set([
FieldType.richText,
FieldType.url,
FieldType.json,
FieldType.singleSelect,
FieldType.multiSelect,
]);
export function useMenuAction(
@@ -95,7 +97,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;
@@ -149,14 +151,22 @@ export function useMenuAction(
const checkEnabled = 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.has(fieldType)) {
return func();
} else {
enqueueSnackbar(
`${selectedCol?.type} field cannot be copied using keyboard shortcut`,
`${fieldType} field cannot be copied using keyboard shortcut`,
{
variant: "info",
variant: "error",
}
);
}

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

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

View File

@@ -132,8 +132,8 @@ const extensionBodyTemplate = {
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
}`,
@@ -144,8 +144,8 @@ const extensionBodyTemplate = {
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
}`,

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

@@ -57,7 +57,6 @@ export default function Step1Columns({
config.pairs.map((pair) => pair.fieldKey)
);
const fieldKeys = Object.keys(airtableData.records[0].fields);
// When a field is selected to be imported
@@ -128,8 +127,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 +157,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 +233,13 @@ 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 +254,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 {",
@@ -101,11 +98,7 @@ export const webhookBasic = {
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<Typography color="text.disabled">
<WarningIcon aria-label="Warning" style={{ verticalAlign: "bottom" }} />

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",
@@ -51,11 +48,7 @@ export const webhookSendgrid = {
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<>
<Typography gutterBottom>

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",
@@ -49,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>
@@ -77,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
@@ -86,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",
@@ -83,11 +80,7 @@ export const webhookTypeform = {
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<>
<Typography gutterBottom>

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",
@@ -51,11 +48,7 @@ export const webhook = {
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`,
},
auth: (
webhookObject: IWebhook,
setWebhookObject: (w: IWebhook) => void,
secrets: ISecret
) => {
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<>
<Typography gutterBottom>

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,21 @@ 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) {
if (typeof value === "string" && value.trim() !== "") {
onChange(value);
} else if (typeof watchedValue === "string" && !!watchedValue) {
onChange(startCase(watchedValue));
}
}
}, [watchedValue, disabled, onChange, value]);
return <ShortTextComponent {...props} />;
}

View File

@@ -16,11 +16,7 @@ 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,
@@ -29,22 +25,33 @@ import {
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({
@@ -120,7 +127,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" },

View File

@@ -112,7 +112,9 @@ export default function Filters() {
setLocalFilters(filtersToApply);
// Reset order so we dont have to make a new index
setTableSorts([]);
if (filtersToApply.length) {
setTableSorts([]);
}
}, [
hasTableFilters,
hasUserFilters,

View File

@@ -62,7 +62,20 @@ 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);
@@ -119,20 +132,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 +148,7 @@ export default function ImportFromFile() {
{}
)
);
console.log(mappedRows);
// console.log(mappedRows);
setImportCsv({
importType: importTypeRef.current,
csvData: { columns, rows: mappedRows },
@@ -161,6 +160,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

@@ -134,14 +134,22 @@ export default function TableToolbar({
</Suspense>
)
)}
<Suspense fallback={<ButtonSkeleton />}>
<TableToolbarButton
title="Export/Download"
onClick={() => openTableModal("export")}
icon={<ExportIcon />}
disabled={disabledTools.includes("export")}
/>
</Suspense>
{(!projectSettings.exporterRoles ||
projectSettings.exporterRoles.length === 0 ||
userRoles.some((role) =>
projectSettings.exporterRoles?.includes(role)
)) && (
<Suspense fallback={<ButtonSkeleton />}>
<TableToolbarButton
title="Export/Download"
onClick={() => openTableModal("export")}
icon={<ExportIcon />}
disabled={disabledTools.includes("export")}
/>
</Suspense>
)}
{userRoles.includes("ADMIN") && (
<>
<div /> {/* Spacer */}

View File

@@ -1,67 +1,69 @@
export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("action started")
// Import any NPM package needed
// const lodash = require('lodash');
// Example:
/*
const authToken = await rowy.secrets.get("service")
try {
const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': authToken
},
body: JSON.stringify(row)
})
return {
success: true,
message: 'User updated successfully on example service',
status: "upto date"
}
} catch (error) {
return {
success: false,
message: 'User update failed on example service',
}
}
*/
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`;
export const RUN_ACTION_TEMPLATE = `// Import any NPM package needed
// import _ from "lodash";
const action: Action = async ({ row, ref, db, storage, auth, actionParams, user, logging }) => {
logging.log("action started");
export const UNDO_ACTION_TEMPLATE = `const action : Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("action started")
// Import any NPM package needed
// const lodash = require('lodash');
// Example:
/*
const authToken = await rowy.secrets.get("service")
// Example:
const authToken = await rowy.secrets.get("service");
try {
const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{
method: 'DELETE',
const resp = await fetch("https://example.com/api/v1/users/" + ref.id, {
method: "PUT",
headers: {
'Content-Type': 'application/json',
'Authorization': authToken
"Content-Type": "application/json",
Authorization: authToken,
},
body: JSON.stringify(row)
})
body: JSON.stringify(row),
});
return {
success: true,
message: 'User deleted successfully on example service',
status: null
}
message: "User updated successfully on example service",
status: "upto date",
};
} catch (error) {
return {
success: false,
message: 'User delete failed on example service',
}
message: "User update failed on example service",
};
}
*/
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`;
};
export default action;
`;
export const UNDO_ACTION_TEMPLATE = `// Import any NPM package needed
// import _ from "lodash";
const action: Action = async ({ row, ref, db, storage, auth, actionParams, user, logging }) => {
logging.log("action started");
/*
// Example:
const authToken = await rowy.secrets.get("service");
try {
const resp = await fetch("https://example.com/api/v1/users/" + ref.id, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: authToken,
},
body: JSON.stringify(row),
});
return {
success: true,
message: "User deleted successfully on example service",
status: null,
};
} catch (error) {
return {
success: false,
message: "User delete failed on example service",
};
}
*/
};
export default action;
`;

View File

@@ -0,0 +1,24 @@
import { useTheme } from "@mui/material";
import { IDisplayCellProps } from "@src/components/fields/types";
export default function Array({ value }: IDisplayCellProps) {
const theme = useTheme();
if (!value) {
return null;
}
return (
<div
style={{
width: "100%",
maxHeight: "100%",
whiteSpace: "pre-wrap",
lineHeight: theme.typography.body2.lineHeight,
fontFamily: theme.typography.fontFamilyMono,
}}
>
{JSON.stringify(value, null, 4)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useRef, useState } from "react";
import {
Button,
ButtonGroup,
ListItemText,
MenuItem,
Select,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import { ChevronDown as ArrowDropDownIcon } from "@src/assets/icons";
import { FieldType } from "@src/components/fields/types";
import { getFieldProp } from "@src/components/fields";
import {
ArraySupportedFields,
ArraySupportedFiledTypes,
} from "./SupportedTypes";
function AddButton({ handleAddNew }: { handleAddNew: Function }) {
const anchorEl = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [fieldType, setFieldType] = useState<ArraySupportedFiledTypes>(
FieldType.shortText
);
return (
<>
<ButtonGroup
variant="contained"
color="primary"
aria-label="Split button"
sx={{ width: "fit-content" }}
ref={anchorEl}
>
<Button
variant="contained"
color="primary"
onClick={() => handleAddNew(fieldType)}
startIcon={<AddIcon />}
>
Add {getFieldProp("name", fieldType)}
</Button>
<Button
variant="contained"
color="primary"
aria-label="Select add element"
aria-haspopup="menu"
style={{ padding: 0 }}
onClick={() => setOpen(true)}
id="add-row-menu-button"
aria-controls={open ? "add-new-element" : undefined}
aria-expanded={open ? "true" : "false"}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Select
id="add-new-element"
open={open}
onClose={() => setOpen(false)}
label="Add new element"
style={{ display: "none" }}
value={fieldType}
onChange={(e) => setFieldType(e.target.value as typeof fieldType)}
MenuProps={{
anchorEl: anchorEl.current,
MenuListProps: { "aria-labelledby": "add-row-menu-button" },
anchorOrigin: { horizontal: "left", vertical: "bottom" },
transformOrigin: { horizontal: "left", vertical: "top" },
}}
>
{ArraySupportedFields.map((fieldType, i) => (
<MenuItem value={fieldType} disabled={false} key={i + ""}>
<ListItemText
primary={getFieldProp("name", fieldType)}
secondary={getFieldProp("description", fieldType)}
secondaryTypographyProps={{
variant: "caption",
whiteSpace: "pre-line",
}}
/>
</MenuItem>
))}
</Select>
</>
);
}
export default AddButton;

View File

@@ -0,0 +1,108 @@
import { DocumentReference, GeoPoint, Timestamp } from "firebase/firestore";
import { FieldType } from "@src/components/fields/types";
import NumberValueSidebar from "@src/components/fields/Number/SideDrawerField";
import ShortTextValueSidebar from "@src/components/fields/ShortText/SideDrawerField";
import JsonValueSidebar from "@src/components/fields/Json/SideDrawerField";
import CheckBoxValueSidebar from "@src/components/fields/Checkbox/SideDrawerField";
import GeoPointValueSidebar from "@src/components/fields/GeoPoint/SideDrawerField";
import DateTimeValueSidebar from "@src/components/fields/DateTime/SideDrawerField";
import ReferenceValueSidebar from "@src/components/fields/Reference/SideDrawerField";
export const ArraySupportedFields = [
FieldType.number,
FieldType.shortText,
FieldType.json,
FieldType.checkbox,
FieldType.geoPoint,
FieldType.dateTime,
FieldType.reference,
] as const;
export type ArraySupportedFiledTypes = typeof ArraySupportedFields[number];
export const SupportedTypes = {
[FieldType.number]: {
Sidebar: NumberValueSidebar,
initialValue: 0,
dataType: "common",
instance: Object,
},
[FieldType.shortText]: {
Sidebar: ShortTextValueSidebar,
initialValue: "",
dataType: "common",
instance: Object,
},
[FieldType.checkbox]: {
Sidebar: CheckBoxValueSidebar,
initialValue: false,
dataType: "common",
instance: Object,
},
[FieldType.json]: {
Sidebar: JsonValueSidebar,
initialValue: {},
sx: [
{
marginTop: "24px",
},
],
dataType: "common",
instance: Object,
},
[FieldType.geoPoint]: {
Sidebar: GeoPointValueSidebar,
initialValue: new GeoPoint(0, 0),
dataType: "firestore-type",
instance: GeoPoint,
},
[FieldType.dateTime]: {
Sidebar: DateTimeValueSidebar,
initialValue: Timestamp.now(),
dataType: "firestore-type",
instance: Timestamp,
},
[FieldType.reference]: {
Sidebar: ReferenceValueSidebar,
initialValue: null,
dataType: "firestore-type",
instance: DocumentReference,
},
};
export function detectType(value: any): ArraySupportedFiledTypes {
if (value === null) {
return FieldType.reference;
}
for (const supportedField of ArraySupportedFields) {
if (SupportedTypes[supportedField].dataType === "firestore-type") {
if (value instanceof SupportedTypes[supportedField].instance) {
return supportedField;
}
}
}
switch (typeof value) {
case "bigint":
case "number": {
return FieldType.number;
}
case "string": {
return FieldType.shortText;
}
case "boolean": {
return FieldType.checkbox;
}
case "object": {
if (+new Date(value)) {
return FieldType.dateTime;
}
return FieldType.json;
}
default: {
return FieldType.shortText;
}
}
}

View File

@@ -0,0 +1,205 @@
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from "react-beautiful-dnd";
import { Stack, Box, Button, ListItem, List } from "@mui/material";
import ClearIcon from "@mui/icons-material/Clear";
import DragIndicatorOutlinedIcon from "@mui/icons-material/DragIndicatorOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutline";
import { FieldType, ISideDrawerFieldProps } from "@src/components/fields/types";
import { TableRowRef } from "@src/types/table";
import AddButton from "./AddButton";
import { getPseudoColumn } from "./utils";
import {
ArraySupportedFiledTypes,
detectType,
SupportedTypes,
} from "./SupportedTypes";
function ArrayFieldInput({
onChange,
value,
_rowy_ref,
index,
onRemove,
onSubmit,
id,
}: {
index: number;
onRemove: (index: number) => void;
onChange: (value: any) => void;
value: any;
onSubmit: () => void;
_rowy_ref: TableRowRef;
id: string;
}) {
const typeDetected = detectType(value);
const Sidebar = SupportedTypes[typeDetected].Sidebar;
return (
<Draggable draggableId={id} index={index} isDragDisabled={false}>
{(provided) => (
<ListItem
sx={[{ padding: 0, marginBottom: "12px" }]}
ref={provided.innerRef}
{...provided.draggableProps}
>
<Box
sx={[{ position: "relative", height: "1.5rem" }]}
{...provided.dragHandleProps}
>
<DragIndicatorOutlinedIcon
color="disabled"
sx={[
{
marginRight: "6px",
opacity: (theme) =>
false ? theme.palette.action.disabledOpacity : 1,
},
]}
/>
</Box>
<Stack
width={"100%"}
sx={
typeDetected === FieldType.json
? SupportedTypes[typeDetected].sx
: null
}
>
<Sidebar
disabled={false}
onDirty={onChange}
onChange={onChange}
onSubmit={onSubmit}
column={getPseudoColumn(typeDetected, index, value)}
value={value}
_rowy_ref={_rowy_ref}
/>
</Stack>
<Box
sx={[{ position: "relative", height: "1.5rem" }]}
onClick={() => onRemove(index)}
>
<DeleteIcon
color="disabled"
sx={[
{
marginLeft: "6px",
":hover": {
cursor: "pointer",
color: "error.main",
},
},
]}
/>
</Box>
</ListItem>
)}
</Draggable>
);
}
export default function ArraySideDrawerField({
column,
value,
onChange,
onSubmit,
disabled,
_rowy_ref,
onDirty,
...props
}: ISideDrawerFieldProps) {
const handleAddNew = (fieldType: ArraySupportedFiledTypes) => {
onChange([...(value || []), SupportedTypes[fieldType].initialValue]);
onDirty(true);
};
const handleChange = (newValue_: any, indexUpdated: number) => {
onChange(
[...(value || [])].map((v: any, i) => {
if (i === indexUpdated) {
return newValue_;
}
return v;
})
);
};
const handleRemove = (index: number) => {
value.splice(index, 1);
onChange([...value]);
onDirty(true);
onSubmit();
};
const handleClearField = () => {
onChange([]);
onSubmit();
};
function handleOnDragEnd(result: DropResult) {
if (
!result.destination ||
result.destination.index === result.source.index
) {
return;
}
const list = Array.from(value);
const [removed] = list.splice(result.source.index, 1);
list.splice(result.destination.index, 0, removed);
onChange(list);
onSubmit();
}
if (value === undefined || Array.isArray(value)) {
return (
<>
<DragDropContext onDragEnd={handleOnDragEnd}>
<Droppable droppableId="columns_manager" direction="vertical">
{(provided) => (
<List {...provided.droppableProps} ref={provided.innerRef}>
{(value || []).map((v: any, index: number) => (
<ArrayFieldInput
key={`index-${index}-value`}
id={`index-${index}-value`}
_rowy_ref={_rowy_ref}
value={v}
onChange={(newValue) => handleChange(newValue, index)}
onRemove={handleRemove}
index={index}
onSubmit={onSubmit}
/>
))}
{provided.placeholder}
</List>
)}
</Droppable>
</DragDropContext>
<AddButton handleAddNew={handleAddNew} />
</>
);
}
return (
<Stack>
<Box component="pre" my="0">
{JSON.stringify(value, null, 4)}
</Box>
<Button
sx={{ mt: 1, width: "fit-content" }}
onClick={handleClearField}
variant="text"
color="warning"
startIcon={<ClearIcon />}
>
Clear field
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,59 @@
import { ColumnConfig } from "@src/types/table";
import { FieldType } from "@src/constants/fields";
import { ArraySupportedFiledTypes } from "./SupportedTypes";
import { GeoPoint, DocumentReference } from "firebase/firestore";
export function getPseudoColumn(
fieldType: FieldType,
index: number,
value: any
): ColumnConfig {
return {
fieldName: (+new Date()).toString(),
index: index,
key: (+new Date()).toString(),
name: value + "",
type: fieldType,
};
}
// archive: detectType / TODO: remove
export function detectType(value: any): ArraySupportedFiledTypes {
if (value === null) {
return FieldType.reference;
}
console.log(typeof GeoPoint);
console.log(value instanceof DocumentReference, value);
if (typeof value === "object") {
const keys = Object.keys(value);
// console.log({ keys, value }, typeof value);
if (keys.length === 2) {
if (keys.includes("_lat") && keys.includes("_long")) {
return FieldType.geoPoint;
}
if (keys.includes("nanoseconds") && keys.includes("seconds")) {
return FieldType.dateTime;
}
}
if (+new Date(value)) {
return FieldType.dateTime;
}
return FieldType.json;
}
switch (typeof value) {
case "bigint":
case "number": {
return FieldType.number;
}
case "string": {
return FieldType.shortText;
}
case "boolean": {
return FieldType.checkbox;
}
default: {
return FieldType.shortText;
}
}
}

View File

@@ -0,0 +1,30 @@
import { lazy } from "react";
import DataArrayIcon from "@mui/icons-material/DataArray";
import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import DisplayCell from "./DisplayCell";
const SideDrawerField = lazy(
() =>
import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Array" */)
);
export const config: IFieldConfig = {
type: FieldType.array,
name: "Array",
group: "Code",
dataType: "object",
initialValue: [],
initializable: true,
icon: <DataArrayIcon />,
description:
"Connects to a sub-table in the current row. Also displays number of rows inside the sub-table. Max sub-table depth: 100.",
TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", {
popoverProps: { PaperProps: { sx: { p: 1, minWidth: "200px" } } },
}),
SideDrawerField,
requireConfiguration: false,
};
export default config;

View File

@@ -108,7 +108,7 @@ export default function PopupContents({
onChange={(e) => setQuery(e.target.value)}
fullWidth
variant="filled"
label="Search items"
// label="Search items"
hiddenLabel
placeholder="Search items"
InputProps={{

View File

@@ -11,16 +11,19 @@ export const replacer = (data: any) => (m: string, key: string) => {
return get(data, objKey, defaultValue);
};
export const baseFunction = `const connectorFn: Connector = async ({query, row, user, logging}) => {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
logging.log("connectorFn started")
// Import any NPM package needed
// const lodash = require('lodash');
return [];
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
};`;
export const baseFunction = `// Import any NPM package needed
// import _ from "lodash";
const connector: Connector = async ({ query, row, user, logging }) => {
logging.log("connector started");
// return [
// { id: "a", name: "Apple" },
// { id: "b", name: "Banana" },
// ];
};
export default connector;
`;
export const getLabel = (config: any, row: TableRow) => {
if (!config.labelFormatter) {

View File

@@ -34,10 +34,14 @@ export const config: IFieldConfig = {
SideDrawerField,
filter: { operators: filterOperators, valueFormatter },
settings: Settings,
csvImportParser: (value, config) =>
parse(value, config?.format ?? DATE_FORMAT, new Date()),
csvExportFormatter: (value: any, config?: any) =>
format(value.toDate(), config?.format ?? DATE_FORMAT),
csvImportParser: (value, config) => parse(value, DATE_FORMAT, new Date()),
csvExportFormatter: (value: any, config?: any) => {
if (typeof value === "number") {
return format(new Date(value), DATE_FORMAT);
} else {
return format(value.toDate(), DATE_FORMAT);
}
},
};
export default config;

View File

@@ -1,7 +1,7 @@
import { lazy } from "react";
import { IFieldConfig, FieldType } from "@src/components/fields/types";
import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell";
import { parseJSON, format } from "date-fns";
import { format } from "date-fns";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import DateTimeIcon from "@mui/icons-material/AccessTime";
@@ -46,9 +46,14 @@ export const config: IFieldConfig = {
customInput: FilterCustomInput,
},
settings: Settings,
csvImportParser: (value) => parseJSON(value).getTime(),
csvExportFormatter: (value: any, config?: any) =>
format(value.toDate(), config?.format ?? DATE_TIME_FORMAT),
csvImportParser: (value) => new Date(value),
csvExportFormatter: (value: any, config?: any) => {
if (typeof value === "number") {
return format(new Date(value), DATE_TIME_FORMAT);
} else {
return format(value.toDate(), DATE_TIME_FORMAT);
}
},
};
export default config;

View File

@@ -75,18 +75,19 @@ export default function Settings({
${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")}
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`
: `const derivative:Derivative = 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("derivative started")
// Import any NPM package needed
// const lodash = require('lodash');
: `// Import any NPM package needed
// import _ from "lodash";
const derivative: Derivative = async ({ row, ref, db, storage, auth, logging }) => {
logging.log("derivative started");
// Example:
// const sum = row.a + row.b;
// return sum;
// WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
}`;
};
export default derivative;
`;
return (
<>

View File

@@ -14,6 +14,15 @@ import { DATE_TIME_FORMAT } from "@src/constants/dates";
import { FileValue } from "@src/types/table";
import useFileUpload from "./useFileUpload";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
ResponderProvided,
} from "react-beautiful-dnd";
export default function File_({
column,
value,
@@ -24,11 +33,40 @@ export default function File_({
}: IEditorCellProps) {
const confirm = useSetAtom(confirmDialogAtom, projectScope);
const { loading, progress, handleDelete, localFiles, dropzoneState } =
useFileUpload(_rowy_ref, column.key, { multiple: true });
const {
loading,
progress,
handleDelete,
localFiles,
dropzoneState,
handleUpdate,
} = useFileUpload(_rowy_ref, column.key, { multiple: true });
const { isDragActive, getRootProps, getInputProps } = dropzoneState;
const dropzoneProps = getRootProps();
const onDragEnd = (result: DropResult, provided: ResponderProvided) => {
const { destination, source } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
const newValue = Array.from(value);
newValue.splice(source.index, 1);
newValue.splice(destination.index, 0, value[source.index]);
handleUpdate([...newValue]);
};
return (
<Stack
direction="row"
@@ -36,6 +74,8 @@ export default function File_({
sx={{
width: "100%",
height: "100%",
py: 0,
pl: 1,
...(isDragActive
? {
@@ -53,70 +93,110 @@ export default function File_({
tabIndex={tabIndex}
onClick={undefined}
>
<ChipList rowHeight={rowHeight}>
{Array.isArray(value) &&
value.map((file: FileValue) => (
<Grid
item
key={file.downloadURL}
style={
// Truncate so multiple files still visible
value.length > 1 ? { maxWidth: `calc(100% - 12px)` } : {}
}
>
<Tooltip
title={`File last modified ${format(
file.lastModifiedTS,
DATE_TIME_FORMAT
)}`}
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="image-droppable" direction="horizontal">
{(provided) => (
<ChipList rowHeight={rowHeight}>
<Grid
container
spacing={0.5}
wrap="nowrap"
ref={provided.innerRef}
{...provided.droppableProps}
>
<Chip
label={file.name}
icon={<FileIcon />}
sx={{
"& .MuiChip-label": {
lineHeight: 5 / 3,
},
}}
onClick={(e: any) => e.stopPropagation()}
component="a"
href={file.downloadURL}
target="_blank"
rel="noopener noreferrer"
clickable
onDelete={
disabled
? undefined
: (e) => {
e.preventDefault();
confirm({
handleConfirm: () => handleDelete(file),
title: "Delete file?",
body: "This file cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
});
}
}
tabIndex={tabIndex}
style={{ width: "100%", cursor: "pointer" }}
/>
</Tooltip>
</Grid>
))}
{localFiles &&
localFiles.map((file) => (
<Grid item key={file.name}>
<Chip
icon={<FileIcon />}
label={file.name}
deleteIcon={
<CircularProgressOptical size={20} color="inherit" />
}
/>
</Grid>
))}
</ChipList>
{Array.isArray(value) &&
value.map((file: FileValue, i) => (
<Draggable
key={file.downloadURL}
draggableId={file.downloadURL}
index={i}
>
{(provided) => (
<Grid
item
ref={provided.innerRef}
{...provided.draggableProps}
style={{
display: "flex",
alignItems: "center",
// Truncate so multiple files still visible
maxWidth: `${
value.length > 1 ? "calc(100% - 12px)" : "initial"
}`,
...provided.draggableProps.style,
}}
>
{value.length > 1 && (
<div
{...provided.dragHandleProps}
style={{
display: "flex",
alignItems: "center",
}}
>
<DragIndicatorIcon />
</div>
)}
<Tooltip
title={`File last modified ${format(
file.lastModifiedTS,
DATE_TIME_FORMAT
)}`}
>
<Chip
label={file.name}
icon={<FileIcon />}
sx={{
"& .MuiChip-label": {
lineHeight: 5 / 3,
},
}}
onClick={(e: any) => e.stopPropagation()}
component="a"
href={file.downloadURL}
target="_blank"
rel="noopener noreferrer"
clickable
onDelete={
disabled
? undefined
: (e) => {
e.preventDefault();
confirm({
handleConfirm: () => handleDelete(file),
title: "Delete file?",
body: "This file cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
});
}
}
tabIndex={tabIndex}
style={{ width: "100%", cursor: "pointer" }}
/>
</Tooltip>
</Grid>
)}
</Draggable>
))}
</Grid>
{localFiles &&
localFiles.map((file) => (
<Grid item key={file.name}>
<Chip
icon={<FileIcon />}
label={file.name}
deleteIcon={
<CircularProgressOptical size={20} color="inherit" />
}
/>
</Grid>
))}
</ChipList>
)}
</Droppable>
</DragDropContext>
{!loading ? (
!disabled && (

View File

@@ -20,6 +20,15 @@ import { FileValue } from "@src/types/table";
import useFileUpload from "./useFileUpload";
import { FileIcon } from ".";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
ResponderProvided,
} from "react-beautiful-dnd";
export default function File_({
column,
_rowy_ref,
@@ -72,52 +81,94 @@ export default function File_({
</ButtonBase>
)}
<Grid container spacing={0.5} style={{ marginTop: 2 }}>
{Array.isArray(value) &&
value.map((file: FileValue) => (
<Grid item key={file.name}>
<Tooltip
title={`File last modified ${format(
file.lastModifiedTS,
DATE_TIME_FORMAT
)}`}
>
<div>
<Chip
icon={<FileIcon />}
label={file.name}
onClick={() => window.open(file.downloadURL)}
onDelete={
!disabled
? () =>
confirm({
title: "Delete file?",
body: "This file cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(file),
})
: undefined
}
/>
</div>
</Tooltip>
</Grid>
))}
<DragDropContext onDragEnd={() => console.log("onDragEnd")}>
<Droppable droppableId="sidebar-file-droppable">
{(provided) => (
<Grid
container
spacing={0.5}
style={{ marginTop: 2 }}
ref={provided.innerRef}
{...provided.droppableProps}
>
{Array.isArray(value) &&
value.map((file: FileValue, i) => (
<Draggable
key={file.downloadURL}
draggableId={file.downloadURL}
index={i}
>
{(provided) => (
<Grid
item
key={file.name}
ref={provided.innerRef}
{...provided.draggableProps}
style={{
display: "flex",
alignItems: "center",
...provided.draggableProps.style,
}}
>
{value.length > 1 && (
<div
{...provided.dragHandleProps}
style={{
display: "flex",
alignItems: "center",
}}
>
<DragIndicatorIcon />
</div>
)}
<Tooltip
title={`File last modified ${format(
file.lastModifiedTS,
DATE_TIME_FORMAT
)}`}
>
<div>
<Chip
icon={<FileIcon />}
label={file.name}
onClick={() => window.open(file.downloadURL)}
onDelete={
!disabled
? () =>
confirm({
title: "Delete file?",
body: "This file cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(file),
})
: undefined
}
/>
</div>
</Tooltip>
</Grid>
)}
</Draggable>
))}
{localFiles &&
localFiles.map((file) => (
<Grid item>
<Chip
icon={<FileIcon />}
label={file.name}
deleteIcon={
<CircularProgressOptical size={20} color="inherit" />
}
/>
{localFiles &&
localFiles.map((file) => (
<Grid item>
<Chip
icon={<FileIcon />}
label={file.name}
deleteIcon={
<CircularProgressOptical size={20} color="inherit" />
}
/>
</Grid>
))}
{provided.placeholder}
</Grid>
))}
</Grid>
)}
</Droppable>
</DragDropContext>
</>
);
}

View File

@@ -79,6 +79,15 @@ export default function useFileUpload(
[deleteUpload, docRef.arrayTableData, docRef.path, fieldName, updateField]
);
// Drag and Drop
const handleUpdate = (files: any) => {
updateField({
path: docRef.path,
fieldName,
value: files,
});
};
return {
localFiles,
progress,
@@ -87,5 +96,6 @@ export default function useFileUpload(
handleUpload,
handleDelete,
dropzoneState,
handleUpdate,
};
}

View File

@@ -7,6 +7,7 @@ import { defaultFn, getDisplayCell } from "./util";
export default function Formula(props: IDisplayCellProps) {
const { result, error, loading } = useFormula({
row: props.row,
ref: props._rowy_ref,
listenerFields: props.column.config?.listenerFields || [],
formulaFn: props.column.config?.formulaFn || defaultFn,
});

View File

@@ -0,0 +1,75 @@
import { Provider, useAtom } from "jotai";
import { currentUserAtom } from "@src/atoms/projectScope";
import {
tableRowsDbAtom,
tableScope,
tableSettingsAtom,
} from "@src/atoms/tableScope";
import TablePage from "@src/pages/Table/TablePage";
import { TableSchema } from "@src/types/table";
import { Box, InputLabel } from "@mui/material";
import TableSourcePreview from "./TableSourcePreview";
const PreviewTable = ({ tableSchema }: { tableSchema: TableSchema }) => {
const [currentUser] = useAtom(currentUserAtom, tableScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
return (
<Box>
<InputLabel>Preview table</InputLabel>
<Provider
key={"preview-table"}
scope={tableScope}
initialValues={[
[currentUserAtom, currentUser],
[tableSettingsAtom, tableSettings],
[tableRowsDbAtom, []],
]}
>
<TableSourcePreview tableSchema={tableSchema} />
<Box
sx={{
maxHeight: 300,
overflow: "auto",
marginTop: 1,
marginLeft: 0,
// table toolbar
"& > div:first-child": {
display: "none",
},
// table grid
"& > div:nth-of-type(2)": {
height: "unset",
},
// emtpy state
"& .empty-state": {
display: "none",
},
// column actions - add column
'& [data-col-id="_rowy_column_actions"]': {
display: "none",
},
// row headers - sort by, column settings
'& [data-row-id="_rowy_header"] > button': {
display: "none",
},
// row headers - drag handler
'& [data-row-id="_rowy_header"] > .column-drag-handle': {
display: "none !important",
},
// row headers - resize handler
'& [data-row-id="_rowy_header"] >:last-child': {
display: "none !important",
},
}}
>
<TablePage disableModals={true} disableSideDrawer={true} />
</Box>
</Provider>
</Box>
);
};
export default PreviewTable;

View File

@@ -1,23 +1,24 @@
import { lazy, Suspense } from "react";
import { lazy, Suspense, useMemo } from "react";
import { useDebouncedCallback } from "use-debounce";
import { useAtom } from "jotai";
import MultiSelect from "@rowy/multiselect";
import { Grid, InputLabel, Stack, FormHelperText } from "@mui/material";
import {
Grid,
InputLabel,
Typography,
Stack,
FormHelperText,
Tooltip,
} from "@mui/material";
tableColumnsOrderedAtom,
tableSchemaAtom,
tableScope,
} from "@src/atoms/tableScope";
import FieldSkeleton from "@src/components/SideDrawer/FieldSkeleton";
import { ISettingsProps } from "@src/components/fields/types";
import { tableColumnsOrderedAtom, tableScope } from "@src/atoms/tableScope";
import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown";
import { DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT } from "@src/components/Table";
import { ColumnConfig } from "@src/types/table";
import { defaultFn, listenerFieldTypes, outputFieldTypes } from "./util";
import PreviewTable from "./PreviewTable";
import { getFieldProp } from "..";
/* eslint-disable import/no-webpack-loader-syntax */
@@ -38,14 +39,41 @@ const diagnosticsOptions = {
export default function Settings({
config,
fieldName,
onChange,
onBlur,
errors,
}: ISettingsProps) {
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
const returnType = getFieldProp("dataType", config.renderFieldType) ?? "any";
const formulaFn = config?.formulaFn ? config.formulaFn : defaultFn;
const previewTableSchema = useMemo(() => {
const columns = tableSchema.columns || {};
return {
...tableSchema,
columns: Object.keys(columns).reduce((previewSchema, key) => {
if ((config.listenerFields || []).includes(columns[key].fieldName)) {
previewSchema[key] = {
...columns[key],
fixed: false,
width: DEFAULT_COL_WIDTH,
};
}
if (columns[key].fieldName === fieldName) {
previewSchema[key] = {
...columns[key],
config,
fixed: true,
};
}
return previewSchema;
}, {} as { [key: string]: ColumnConfig }),
rowHeight: DEFAULT_ROW_HEIGHT,
};
}, [config, fieldName, tableSchema]);
return (
<Stack spacing={1}>
<Grid container direction="row" spacing={2} flexWrap="nowrap">
@@ -107,7 +135,11 @@ export default function Settings({
additionalVariables={[
{
key: "row",
description: `Current row's data`,
description: `row has the value of doc.data() it has type definitions using this table's schema, but you can only access formula's listener fields.`,
},
{
key: "ref",
description: `reference object that holds the readonly reference of the row document.(i.e ref.id)`,
},
]}
/>
@@ -125,6 +157,7 @@ export default function Settings({
/>
</Suspense>
</div>
<PreviewTable tableSchema={previewTableSchema} />
</Stack>
);
}

View File

@@ -0,0 +1,83 @@
import { useCallback, useEffect } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useAtomCallback } from "jotai/utils";
import { cloneDeep, findIndex, sortBy } from "lodash-es";
import {
_deleteRowDbAtom,
_updateRowDbAtom,
tableNextPageAtom,
tableRowsDbAtom,
tableSchemaAtom,
tableScope,
tableSettingsAtom,
} from "@src/atoms/tableScope";
import { TableRow, TableSchema } from "@src/types/table";
import { updateRowData } from "@src/utils/table";
import { serializeRef } from "./util";
const TableSourcePreview = ({ tableSchema }: { tableSchema: TableSchema }) => {
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const setTableSchemaAtom = useSetAtom(tableSchemaAtom, tableScope);
const setRows = useSetAtom(tableRowsDbAtom, tableScope);
useEffect(() => {
setRows(
["preview-doc-1", "preview-doc-2", "preview-doc-3"].map((docId) => ({
_rowy_ref: serializeRef(`${tableSettings.collection}/${docId}`),
}))
);
}, [setRows, tableSettings.collection]);
useEffect(() => {
setTableSchemaAtom(() => ({
...tableSchema,
_rowy_ref: "preview",
}));
}, [tableSchema, setTableSchemaAtom]);
const readRowsDb = useAtomCallback(
useCallback((get) => get(tableRowsDbAtom) || [], []),
tableScope
);
const setUpdateRowDb = useSetAtom(_updateRowDbAtom, tableScope);
setUpdateRowDb(() => async (path: string, update: Partial<TableRow>) => {
const rows = await readRowsDb();
const index = findIndex(rows, ["_rowy_ref.path", path]);
if (index === -1) {
setRows(
sortBy(
[
...rows,
{ ...update, _rowy_ref: { id: path.split("/").pop()!, path } },
],
["_rowy_ref.id"]
)
);
} else {
const updatedRows = [...rows];
updatedRows[index] = cloneDeep(rows[index]);
updatedRows[index] = updateRowData(updatedRows[index], update);
setRows(updatedRows);
}
return Promise.resolve();
});
const setDeleteRowDb = useSetAtom(_deleteRowDbAtom, tableScope);
setDeleteRowDb(() => async (path: string) => {
const rows = await readRowsDb();
const index = findIndex(rows, ["_rowy_ref.path", path]);
if (index > -1) {
setRows(rows.filter((_, idx) => idx !== index));
}
return Promise.resolve();
});
const setNextPageAtom = useSetAtom(tableNextPageAtom, tableScope);
setNextPageAtom({ loading: false, available: false });
return null;
};
export default TableSourcePreview;

View File

@@ -1,8 +1,9 @@
type RowRef<T> = { id: string; path: string; parent: T };
interface Ref extends RowRef<Ref> {}
type FormulaContext = {
row: Row;
// ref: FirebaseFirestore.DocumentReference;
// storage: firebasestorage.Storage;
// db: FirebaseFirestore.Firestore;
ref: Ref;
};
type Formula = (context: FormulaContext) => "PLACEHOLDER_OUTPUT_TYPE";

View File

@@ -2,17 +2,23 @@ import { useEffect, useMemo, useState } from "react";
import { pick, zipObject } from "lodash-es";
import { useAtom } from "jotai";
import { TableRow } from "@src/types/table";
import { TableRow, TableRowRef } from "@src/types/table";
import { tableColumnsOrderedAtom, tableScope } from "@src/atoms/tableScope";
import { listenerFieldTypes, useDeepCompareMemoize } from "./util";
import {
listenerFieldTypes,
serializeRef,
useDeepCompareMemoize,
} from "./util";
export const useFormula = ({
row,
ref,
listenerFields,
formulaFn,
}: {
row: TableRow;
ref: TableRowRef;
listenerFields: string[];
formulaFn: string;
}) => {
@@ -58,10 +64,13 @@ export const useFormula = ({
setLoading(false);
};
worker.postMessage({
formulaFn,
row: availableFields,
});
worker.postMessage(
JSON.stringify({
formulaFn,
row: availableFields,
ref: serializeRef(ref.path),
})
);
return () => {
worker.terminate();

View File

@@ -24,6 +24,8 @@ import JsonDisplayCell from "@src/components/fields/Json/DisplayCell";
import CodeDisplayCell from "@src/components/fields/Code/DisplayCell";
import MarkdownDisplayCell from "@src/components/fields/Markdown/DisplayCell";
import CreatedByDisplayCell from "@src/components/fields/CreatedBy/DisplayCell";
import { TableRowRef } from "@src/types/table";
import { DocumentData, DocumentReference } from "firebase/firestore";
export function useDeepCompareMemoize<T>(value: T) {
const ref = useRef<T>(value);
@@ -65,7 +67,7 @@ export const outputFieldTypes = Object.values(FieldType).filter(
].includes(type)
);
export const defaultFn = `const formula:Formula = async ({ row })=> {
export const defaultFn = `const formula:Formula = async ({ row, ref })=> {
// WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY
// Example:
@@ -120,3 +122,23 @@ export const getDisplayCell = (type: FieldType) => {
return ShortTextDisplayCell;
}
};
export const serializeRef = (path: string, maxDepth = 20) => {
const pathArr = path.split("/");
const serializedRef = {
path: pathArr.join("/"),
id: pathArr.pop(),
} as any;
let curr: TableRowRef | Partial<DocumentReference<DocumentData>> =
serializedRef;
let depth = 0;
while (pathArr.length > 0 && curr && depth < maxDepth) {
(curr.parent as any) = {
path: pathArr.join("/"),
id: pathArr.pop(),
} as Partial<DocumentReference<DocumentData>>;
curr = curr.parent as any;
maxDepth++;
}
return serializedRef;
};

View File

@@ -1,14 +1,15 @@
onmessage = async ({ data }) => {
try {
const { formulaFn, row } = data;
const { formulaFn, row, ref } = JSON.parse(data);
const AsyncFunction = async function () {}.constructor as any;
const [_, fnBody] = formulaFn.match(/=>\s*({?[\s\S]*}?)$/);
if (!fnBody) return;
const fn = new AsyncFunction(
"row",
"ref",
`const fn = async () => \n${fnBody}\n return fn();`
);
const result = await fn(row);
const result = await fn(row, ref);
postMessage({ result });
} catch (error: any) {
console.error("Error: ", error);

View File

@@ -25,17 +25,5 @@ export const config: IFieldConfig = {
popoverProps: { PaperProps: { sx: { p: 1, pt: 0 } } },
}),
SideDrawerField,
csvImportParser: (value: string) => {
try {
const { latitude, longitude } = JSON.parse(value);
if (latitude && longitude) {
return new GeoPoint(latitude, longitude);
}
throw new Error();
} catch (e) {
console.error("Invalid GeoPoint value");
return null;
}
},
};
export default config;

View File

@@ -16,6 +16,15 @@ import useFileUpload from "@src/components/fields/File/useFileUpload";
import { IMAGE_MIME_TYPES } from "./index";
import { imgSx, thumbnailSx, deleteImgHoverSx } from "./DisplayCell";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
ResponderProvided,
} from "react-beautiful-dnd";
export default function Image_({
column,
value,
@@ -26,11 +35,17 @@ export default function Image_({
}: IEditorCellProps) {
const confirm = useSetAtom(confirmDialogAtom, projectScope);
const { loading, progress, handleDelete, localFiles, dropzoneState } =
useFileUpload(_rowy_ref, column.key, {
multiple: true,
accept: IMAGE_MIME_TYPES,
});
const {
loading,
progress,
handleDelete,
localFiles,
dropzoneState,
handleUpdate,
} = useFileUpload(_rowy_ref, column.key, {
multiple: true,
accept: IMAGE_MIME_TYPES,
});
const localImages = useMemo(
() =>
@@ -43,6 +58,28 @@ export default function Image_({
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
const dropzoneProps = getRootProps();
const onDragEnd = (result: DropResult, provided: ResponderProvided) => {
const { destination, source } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
const newValue = Array.from(value);
newValue.splice(source.index, 1);
newValue.splice(destination.index, 0, value[source.index]);
handleUpdate([...newValue]);
};
let thumbnailSize = "100x100";
if (rowHeight > 50) thumbnailSize = "200x200";
if (rowHeight > 100) thumbnailSize = "400x400";
@@ -82,62 +119,102 @@ export default function Image_({
marginLeft: "0 !important",
}}
>
<Grid container spacing={0.5} wrap="nowrap">
{Array.isArray(value) &&
value.map((file: FileValue, i) => (
<Grid item key={file.downloadURL}>
<ButtonBase
aria-label="Delete…"
sx={imgSx(rowHeight)}
className="img"
onClick={() => {
confirm({
title: "Delete image?",
body: "This image cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(file),
});
}}
disabled={disabled}
tabIndex={tabIndex}
>
<Thumbnail
imageUrl={file.downloadURL}
size={thumbnailSize}
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={deleteImgHoverSx}
>
<DeleteIcon color="error" />
</Grid>
</ButtonBase>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="image-droppable" direction="horizontal">
{(provided) => (
<Grid
container
spacing={0.5}
wrap="nowrap"
ref={provided.innerRef}
{...provided.droppableProps}
>
{Array.isArray(value) &&
value.map((file: FileValue, i) => (
<Draggable
key={file.downloadURL}
draggableId={file.downloadURL}
index={i}
>
{(provided) => (
<Grid
item
ref={provided.innerRef}
{...provided.draggableProps}
style={{
display: "flex",
alignItems: "center",
...provided.draggableProps.style,
}}
>
{value.length > 1 && (
<div
{...provided.dragHandleProps}
style={{
display: "flex",
alignItems: "center",
}}
>
<DragIndicatorIcon />
</div>
)}
<ButtonBase
aria-label="Delete…"
sx={imgSx(rowHeight)}
className="img"
onClick={() => {
confirm({
title: "Delete image?",
body: "This image cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(file),
});
}}
disabled={disabled}
tabIndex={tabIndex}
>
<Thumbnail
imageUrl={file.downloadURL}
size={thumbnailSize}
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={deleteImgHoverSx}
>
<DeleteIcon color="error" />
</Grid>
</ButtonBase>
</Grid>
)}
</Draggable>
))}
{localImages &&
localImages.map((image) => (
<Grid item>
<Box
sx={[
imgSx(rowHeight),
{
boxShadow: (theme) =>
`0 0 0 1px ${theme.palette.divider} inset`,
},
]}
style={{
backgroundImage: `url("${image.localURL}")`,
}}
/>
</Grid>
))}
{provided.placeholder}
</Grid>
))}
{localImages &&
localImages.map((image) => (
<Grid item>
<Box
sx={[
imgSx(rowHeight),
{
boxShadow: (theme) =>
`0 0 0 1px ${theme.palette.divider} inset`,
},
]}
style={{
backgroundImage: `url("${image.localURL}")`,
}}
/>
</Grid>
))}
</Grid>
)}
</Droppable>
</DragDropContext>
</div>
{!loading ? (

View File

@@ -26,6 +26,15 @@ import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
import useFileUpload from "@src/components/fields/File/useFileUpload";
import { IMAGE_MIME_TYPES } from ".";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
ResponderProvided,
} from "react-beautiful-dnd";
const imgSx = {
position: "relative",
width: 80,
@@ -94,6 +103,7 @@ export default function Image_({
uploaderState,
localFiles,
dropzoneState,
handleUpdate,
} = useFileUpload(_rowy_ref, column.key, {
multiple: true,
accept: IMAGE_MIME_TYPES,
@@ -109,6 +119,28 @@ export default function Image_({
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
const onDragEnd = (result: DropResult, provided: ResponderProvided) => {
const { destination, source } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
const newValue = Array.from(value);
newValue.splice(source.index, 1);
newValue.splice(destination.index, 0, value[source.index]);
handleUpdate([...newValue]);
};
return (
<>
{!disabled && (
@@ -151,112 +183,158 @@ export default function Image_({
</ButtonBase>
)}
<Grid container spacing={1} style={{ marginTop: 0 }}>
{Array.isArray(value) &&
value.map((image: FileValue) => (
<Grid item key={image.name}>
{disabled ? (
<Tooltip title="Open">
<ButtonBase
sx={imgSx}
onClick={() => window.open(image.downloadURL, "_blank")}
className="img"
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sidebar-image-droppable" direction="horizontal">
{(provided) => (
<Grid
container
spacing={1}
style={{ marginTop: 0 }}
ref={provided.innerRef}
{...provided.droppableProps}
>
{Array.isArray(value) &&
value.map((image: FileValue, i) => (
<Draggable
key={image.downloadURL}
draggableId={image.downloadURL}
index={i}
>
<Thumbnail
imageUrl={image.downloadURL}
size="200x200"
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={[overlaySx, deleteImgHoverSx]}
{(provided) => (
<Grid item>
{disabled ? (
<Tooltip title="Open">
<ButtonBase
sx={imgSx}
onClick={() =>
window.open(image.downloadURL, "_blank")
}
className="img"
>
<Thumbnail
imageUrl={image.downloadURL}
size="200x200"
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={[overlaySx, deleteImgHoverSx]}
>
{disabled ? (
<OpenIcon />
) : (
<DeleteIcon color="error" />
)}
</Grid>
</ButtonBase>
</Tooltip>
) : (
<div
ref={provided.innerRef}
{...provided.draggableProps}
style={{
display: "flex",
alignItems: "center",
...provided.draggableProps.style,
}}
>
{value.length > 1 && (
<div
{...provided.dragHandleProps}
style={{
display: "flex",
alignItems: "center",
}}
>
<DragIndicatorIcon />
</div>
)}
<Box sx={imgSx} className="img">
<Thumbnail
imageUrl={image.downloadURL}
size="200x200"
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={[overlaySx, deleteImgHoverSx]}
>
<Tooltip title="Delete…">
<IconButton
onClick={() =>
confirm({
title: "Delete image?",
body: "This image cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () =>
handleDelete(image),
})
}
>
<DeleteIcon color="error" />
</IconButton>
</Tooltip>
<Tooltip title="Open">
<IconButton
onClick={() =>
window.open(image.downloadURL, "_blank")
}
>
<OpenIcon />
</IconButton>
</Tooltip>
</Grid>
</Box>
</div>
)}
</Grid>
)}
</Draggable>
))}
{localImages &&
localImages.map((image) => (
<Grid item key={image.name}>
<ButtonBase
sx={imgSx}
style={{
backgroundImage: `url("${image.localURL}")`,
}}
className="img"
>
{disabled ? <OpenIcon /> : <DeleteIcon color="error" />}
</Grid>
</ButtonBase>
</Tooltip>
) : (
<div>
<Box sx={imgSx} className="img">
<Thumbnail
imageUrl={image.downloadURL}
size="200x200"
objectFit="contain"
sx={thumbnailSx}
/>
<Grid
container
justifyContent="center"
alignItems="center"
sx={[overlaySx, deleteImgHoverSx]}
>
<Tooltip title="Delete…">
<IconButton
onClick={() =>
confirm({
title: "Delete image?",
body: "This image cannot be recovered after",
confirm: "Delete",
confirmColor: "error",
handleConfirm: () => handleDelete(image),
})
}
{uploaderState[image.name] && (
<Grid
container
justifyContent="center"
alignItems="center"
sx={overlaySx}
>
<DeleteIcon color="error" />
</IconButton>
</Tooltip>
<Tooltip title="Open">
<IconButton
onClick={() =>
window.open(image.downloadURL, "_blank")
}
>
<OpenIcon />
</IconButton>
</Tooltip>
</Grid>
</Box>
</div>
)}
</Grid>
))}
{localImages &&
localImages.map((image) => (
<Grid item key={image.name}>
<ButtonBase
sx={imgSx}
style={{
backgroundImage: `url("${image.localURL}")`,
}}
className="img"
>
{uploaderState[image.name] && (
<Grid
container
justifyContent="center"
alignItems="center"
sx={overlaySx}
>
<CircularProgressOptical
color="inherit"
size={48}
variant={
uploaderState[image.name].progress === 0
? "indeterminate"
: "determinate"
}
value={uploaderState[image.name].progress}
/>
<CircularProgressOptical
color="inherit"
size={48}
variant={
uploaderState[image.name].progress === 0
? "indeterminate"
: "determinate"
}
value={uploaderState[image.name].progress}
/>
</Grid>
)}
</ButtonBase>
</Grid>
)}
</ButtonBase>
))}
{provided.placeholder}
</Grid>
))}
</Grid>
)}
</Droppable>
</DragDropContext>
</>
);
}

View File

@@ -1,12 +1,16 @@
import { IDisplayCellProps } from "@src/components/fields/types";
import { ButtonBase, Grid, Tooltip } from "@mui/material";
import { ButtonBase, Grid, Tooltip, useTheme } from "@mui/material";
import WarningIcon from "@mui/icons-material/WarningAmber";
import { ChevronDown } from "@src/assets/icons";
import { sanitiseValue } from "./utils";
import ChipList from "@src/components/Table/TableCell/ChipList";
import FormattedChip from "@src/components/FormattedChip";
import {
getColors,
IColors,
} from "@src/components/fields/SingleSelect/Settings";
export default function MultiSelect({
value,
@@ -14,7 +18,11 @@ export default function MultiSelect({
disabled,
tabIndex,
rowHeight,
column,
}: IDisplayCellProps) {
const colors: IColors[] = column?.config?.colors ?? [];
const { mode } = useTheme().palette;
const rendered =
typeof value === "string" && value !== "" ? (
<div style={{ flexGrow: 1, paddingLeft: "var(--cell-padding)" }}>
@@ -30,7 +38,12 @@ export default function MultiSelect({
(item) =>
typeof item === "string" && (
<Grid item key={item}>
<FormattedChip label={item} />
<FormattedChip
label={item}
sx={{
backgroundColor: getColors(colors, item)[mode],
}}
/>
</Grid>
)
)}

View File

@@ -1,12 +1,14 @@
import { ISideDrawerFieldProps } from "@src/components/fields/types";
import { Grid, Button, Tooltip } from "@mui/material";
import { Grid, Button, Tooltip, useTheme } from "@mui/material";
import WarningIcon from "@mui/icons-material/WarningAmber";
import MultiSelectComponent from "@rowy/multiselect";
import FormattedChip from "@src/components/FormattedChip";
import { fieldSx } from "@src/components/SideDrawer/utils";
import { sanitiseValue } from "./utils";
import { getColors } from "@src/components/fields/SingleSelect/Settings";
import palette, { paletteToMui } from "@src/theme/palette";
export default function MultiSelect({
column,
@@ -15,7 +17,10 @@ export default function MultiSelect({
onSubmit,
disabled,
}: ISideDrawerFieldProps) {
const defaultColor = paletteToMui(palette.aGray);
const config = column.config ?? {};
const colors = column.config?.colors ?? [];
const { mode } = useTheme().palette;
const handleDelete = (index: number) => () => {
const newValues = [...value];
@@ -75,6 +80,10 @@ export default function MultiSelect({
<FormattedChip
label={item}
onDelete={disabled ? undefined : handleDelete(i)}
sx={{
backgroundColor:
getColors(colors, item)[mode] || defaultColor[mode],
}}
/>
</Grid>
)

View File

@@ -29,5 +29,6 @@ export const config: IFieldConfig = {
}),
SideDrawerField,
filter: { operators: filterOperators, valueFormatter: valueFormatter },
csvExportFormatter: (value: any) => value?.path,
};
export default config;

View File

@@ -1,16 +1,24 @@
import { IDisplayCellProps } from "@src/components/fields/types";
import { ButtonBase } from "@mui/material";
import { ButtonBase, Chip } from "@mui/material";
import { ChevronDown } from "@src/assets/icons";
import { useTheme } from "@mui/material";
import { sanitiseValue } from "./utils";
import ChipList from "@src/components/Table/TableCell/ChipList";
import { getColors, IColors } from "./Settings";
export default function SingleSelect({
value,
showPopoverCell,
disabled,
tabIndex,
column,
rowHeight,
}: IDisplayCellProps) {
const colors: IColors[] = column?.config?.colors ?? [];
const { mode } = useTheme().palette;
const rendered = (
<div
style={{
@@ -19,7 +27,17 @@ export default function SingleSelect({
paddingLeft: "var(--cell-padding)",
}}
>
{sanitiseValue(value)}
<ChipList rowHeight={rowHeight}>
{value && (
<Chip
size="small"
label={sanitiseValue(value)}
sx={{
backgroundColor: getColors(colors, value)[mode],
}}
/>
)}
</ChipList>
</div>
);

View File

@@ -14,6 +14,9 @@ import {
} from "@mui/material";
import AddIcon from "@mui/icons-material/AddCircle";
import RemoveIcon from "@mui/icons-material/CancelRounded";
import ColorSelect, {
SelectColorThemeOptions,
} from "@src/components/SelectColors";
import {
DragDropContext,
@@ -23,6 +26,7 @@ import {
NotDraggingStyle,
} from "react-beautiful-dnd";
import DragIndicatorOutlinedIcon from "@mui/icons-material/DragIndicatorOutlined";
import palette, { paletteToMui } from "@src/theme/palette";
const getItemStyle = (
isDragging: boolean,
@@ -33,10 +37,29 @@ const getItemStyle = (
...draggableStyle,
});
export interface IColors extends SelectColorThemeOptions {
name: string;
}
export const getColors = (
list: IColors[],
option: string
): SelectColorThemeOptions => {
const defaultColor = paletteToMui(palette.aGray);
const key = option.toLocaleLowerCase().replace(" ", "_").trim();
const color = list.find((opt: IColors) => opt.name === key);
// Null check in return
return color || defaultColor;
};
export default function Settings({ onChange, config }: ISettingsProps) {
const listEndRef: any = useRef(null);
const options = config.options ?? [];
const [newOption, setNewOption] = useState("");
/* State for holding Chip Colors for Select and MultiSelect */
let colors = config.colors ?? [];
const handleAdd = () => {
if (newOption.trim() !== "") {
if (options.includes(newOption)) {
@@ -49,6 +72,38 @@ export default function Settings({ onChange, config }: ISettingsProps) {
}
};
const handleChipColorChange = (
type: "save" | "delete",
key: string,
color?: SelectColorThemeOptions
) => {
const _key = key.toLocaleLowerCase().replace(" ", "_").trim();
const exists = colors.findIndex((option: IColors) => option.name === _key);
// If saving Check if object with the `color.name` is equal to `_key` and replace value at the index of `exists`
// Else save new value with `_key` as `color.name`
if (type === "save") {
if (exists !== -1) {
colors[exists] = { name: _key, ...{ ...color } };
onChange("colors")(colors);
} else {
onChange("colors")([...colors, { name: _key, ...{ ...color } }]);
}
}
// If deleting Filter out object that has `color.name` equals to `_key`
if (type === "delete") {
const updatedColors = colors.filter(
(option: IColors) => option.name !== _key
);
onChange("colors")(updatedColors);
}
};
const handleItemDelete = (option: string) => {
onChange("options")(options.filter((o: string) => o !== option));
handleChipColorChange("delete", option);
};
const handleOnDragEnd = (result: any) => {
if (!result.destination) return;
const [removed] = options.splice(result.source.index, 1);
@@ -92,6 +147,7 @@ export default function Settings({ onChange, config }: ISettingsProps) {
{...provided.dragHandleProps}
item
sx={{ display: "flex" }}
alignItems="center"
>
<DragIndicatorOutlinedIcon
color="disabled"
@@ -101,16 +157,26 @@ export default function Settings({ onChange, config }: ISettingsProps) {
},
]}
/>
<Typography>{option}</Typography>
<Grid
container
direction="row"
alignItems="center"
gap={2}
>
<ColorSelect
key={option}
initialValue={getColors(colors, option)}
handleChange={(color) =>
handleChipColorChange("save", option, color)
}
/>
<Typography>{option}</Typography>
</Grid>
</Grid>
<Grid item>
<IconButton
aria-label="Remove"
onClick={() =>
onChange("options")(
options.filter((o: string) => o !== option)
)
}
onClick={() => handleItemDelete(option)}
>
{<RemoveIcon />}
</IconButton>

View File

@@ -21,7 +21,7 @@ export const config: IFieldConfig = {
type: FieldType.singleSelect,
name: "Single Select",
group: "Select",
dataType: "string | null",
dataType: "string",
initialValue: null,
initializable: true,
icon: <SingleSelectIcon />,

View File

@@ -2,6 +2,7 @@ import { useLocation } from "react-router-dom";
import { ROUTES } from "@src/constants/routes";
import { ColumnConfig, TableRow, TableRowRef } from "@src/types/table";
import get from "lodash-es/get";
export const useSubTableData = (
column: ColumnConfig,
@@ -9,8 +10,8 @@ export const useSubTableData = (
_rowy_ref: TableRowRef
) => {
const label = (column.config?.parentLabel ?? []).reduce((acc, curr) => {
if (acc !== "") return `${acc} - ${row[curr]}`;
else return row[curr];
if (acc !== "") return `${acc} - ${get(row, curr)}`;
else return get(row, curr);
}, "");
const documentCount: string = row[column.fieldName]?.count ?? "";

View File

@@ -1,30 +1,111 @@
import { useAtom } from "jotai";
import { Avatar, AvatarGroup, ButtonBase, Stack, Tooltip } from "@mui/material";
import { allUsersAtom, projectScope } from "@src/atoms/projectScope";
import { IDisplayCellProps } from "@src/components/fields/types";
import { ChevronDown } from "@src/assets/icons/ChevronDown";
import { UserDataType } from "./UserSelect";
import { Tooltip, Stack, Avatar } from "@mui/material";
export default function User({
value,
showPopoverCell,
disabled,
tabIndex,
}: IDisplayCellProps) {
const [users] = useAtom(allUsersAtom, projectScope);
import { format } from "date-fns";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
let userValue: UserDataType[] = [];
let emails = new Set();
export default function User({ value, column }: IDisplayCellProps) {
if (!value || !value.displayName) return null;
if (value !== undefined && value !== null) {
if (!Array.isArray(value)) {
value = [value.email];
}
for (const user of users) {
if (user.user && user.user?.email && value.includes(user.user.email)) {
if (!emails.has(user.user.email)) {
emails.add(user.user.email);
userValue.push(user.user);
}
}
}
}
const chip = (
<Stack spacing={0.75} direction="row" alignItems="center">
<Avatar
alt="Avatar"
src={value.photoURL}
style={{ width: 20, height: 20 }}
/>
<span>{value.displayName}</span>
if (userValue.length === 0) {
return (
<ButtonBase
onClick={() => showPopoverCell(true)}
style={{
width: "100%",
height: "100%",
font: "inherit",
color: "inherit !important",
letterSpacing: "inherit",
textAlign: "inherit",
justifyContent: "flex-end",
}}
tabIndex={tabIndex}
>
<ChevronDown className="row-hover-iconButton end" />
</ButtonBase>
);
}
const rendered = (
<Stack
spacing={0.75}
direction="row"
alignItems="center"
style={{
flexGrow: 1,
overflow: "hidden",
paddingLeft: "var(--cell-padding)",
}}
>
{userValue.length > 1 ? (
<AvatarGroup
sx={{
"& .MuiAvatar-root": { width: 20, height: 20, fontSize: 12 },
}}
max={5}
>
{userValue.map((user: UserDataType) => (
<Tooltip title={`${user.displayName}(${user.email})`}>
<Avatar alt={user.displayName} src={user.photoURL} />
</Tooltip>
))}
</AvatarGroup>
) : (
<>
<Avatar
alt="Avatar"
src={userValue[0].photoURL}
style={{ width: 20, height: 20 }}
/>
<span>{userValue[0].displayName}</span>
</>
)}
</Stack>
);
if (!value.timestamp) return chip;
const dateLabel = format(
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
column.config?.format || DATE_TIME_FORMAT
if (disabled) {
return rendered;
}
return (
<ButtonBase
onClick={() => showPopoverCell(true)}
style={{
width: "100%",
height: "100%",
font: "inherit",
color: "inherit !important",
letterSpacing: "inherit",
textAlign: "inherit",
justifyContent: "flex-start",
}}
tabIndex={tabIndex}
>
{rendered}
<ChevronDown className="row-hover-iconButton end" />
</ButtonBase>
);
return <Tooltip title={dateLabel}>{chip}</Tooltip>;
}

View File

@@ -0,0 +1,6 @@
import { IEditorCellProps } from "@src/components/fields/types";
import UserSelect from "./UserSelect";
export default function EditorCell({ ...props }: IEditorCellProps) {
return <UserSelect {...props} />;
}

View File

@@ -0,0 +1,25 @@
import { Typography, FormControlLabel, Checkbox } from "@mui/material";
import { ISettingsProps } from "@src/components/fields/types";
export default function Settings({ onChange, config }: ISettingsProps) {
return (
<FormControlLabel
value="required"
label={
<>
Accept multiple value
<Typography variant="caption" color="text.secondary" display="block">
Make this column to support multiple values.
</Typography>
</>
}
control={
<Checkbox
checked={config?.multiple}
onChange={(e) => onChange("multiple")(e.target.checked)}
name="multiple"
/>
}
/>
);
}

View File

@@ -1,50 +1,101 @@
import { format } from "date-fns";
import { useRef, useState } from "react";
import { useAtom } from "jotai";
import { Tooltip, Stack, AvatarGroup, Avatar } from "@mui/material";
import { allUsersAtom, projectScope } from "@src/atoms/projectScope";
import { fieldSx } from "@src/components/SideDrawer/utils";
import { ChevronDown } from "@src/assets/icons/ChevronDown";
import { ISideDrawerFieldProps } from "@src/components/fields/types";
import UserSelect, { UserDataType } from "./UserSelect";
import { Box, Stack, Typography, Avatar } from "@mui/material";
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
export default function User({
export default function SideDrawerSelect({
column,
_rowy_ref,
value,
onDirty,
onChange,
onSubmit,
disabled,
}: ISideDrawerFieldProps) {
if (!value || !value.displayName || !value.timestamp)
return <Box sx={fieldSx} />;
const [open, setOpen] = useState(false);
const [users] = useAtom(allUsersAtom, projectScope);
const parentRef = useRef(null);
const dateLabel = value.timestamp
? format(
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
column.config?.format || DATE_TIME_FORMAT
)
: null;
let userValue: UserDataType[] = [];
let emails = new Set();
if (value !== undefined && value !== null) {
if (!Array.isArray(value)) {
value = [value.email];
}
for (const user of users) {
if (user.user && user.user?.email && value.includes(user.user.email)) {
if (!emails.has(user.user.email)) {
emails.add(user.user.email);
userValue.push(user.user);
}
}
}
}
return (
<Stack direction="row" sx={fieldSx} id={getFieldId(column.key)}>
<Avatar
alt="Avatar"
src={value.photoURL}
sx={{ width: 32, height: 32, ml: -0.5, mr: 1.5, my: 0.5 }}
/>
<Typography
variant="body2"
component="div"
style={{ whiteSpace: "normal" }}
<>
<Stack
ref={parentRef}
onClick={() => setOpen(true)}
direction="row"
sx={[
fieldSx,
{
alignItems: "center",
justifyContent: userValue.length > 0 ? "space-between" : "flex-end",
marginTop: "8px",
marginBottom: "8px",
},
]}
>
{value.displayName} ({value.email})
{dateLabel && (
<Typography variant="caption" color="textSecondary" component="div">
{dateLabel}
</Typography>
{userValue.length === 0 ? null : userValue.length > 1 ? (
<AvatarGroup
sx={{
"& .MuiAvatar-root": { width: 20, height: 20, fontSize: 12 },
}}
max={20}
>
{userValue.map(
(user: UserDataType) =>
user && (
<Tooltip title={`${user.displayName}(${user.email})`}>
<Avatar alt={user.displayName} src={user.photoURL} />
</Tooltip>
)
)}
</AvatarGroup>
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<Avatar
alt="Avatar"
src={userValue[0].photoURL}
style={{ width: 20, height: 20 }}
/>
<span>{userValue[0].displayName}</span>
</div>
)}
</Typography>
</Stack>
<ChevronDown className="row-hover-iconButton end" />
</Stack>
<UserSelect
open={open}
value={value}
onChange={onChange}
onSubmit={onSubmit}
parentRef={parentRef.current}
column={column}
showPopoverCell={setOpen}
disabled={disabled}
/>
</>
);
}

View File

@@ -0,0 +1,165 @@
import { useMemo } from "react";
import { useAtom } from "jotai";
import MultiSelect from "@rowy/multiselect";
import {
AutocompleteProps,
Avatar,
Box,
PopoverProps,
Stack,
} from "@mui/material";
import { createFilterOptions } from "@mui/material/Autocomplete";
import { projectScope, allUsersAtom } from "@src/atoms/projectScope";
import { ColumnConfig } from "@src/types/table";
export type UserDataType = {
email: string;
displayName?: string;
photoURL?: string;
phoneNumber?: string;
};
type UserOptionType = {
label: string;
value: string;
user: UserDataType;
};
interface IUserSelectProps<T = any> {
open?: boolean;
value: T;
onChange: (value: T) => void;
onSubmit: () => void;
parentRef?: PopoverProps["anchorEl"];
column: ColumnConfig;
disabled: boolean;
showPopoverCell: (value: boolean) => void;
}
export default function UserSelect({
open,
value,
onChange,
onSubmit,
parentRef,
column,
showPopoverCell,
disabled,
}: IUserSelectProps) {
const [users] = useAtom(allUsersAtom, projectScope);
const options = useMemo(() => {
let options: UserOptionType[] = [];
let emails = new Set();
for (const user of users) {
if (user.user && user.user?.email) {
if (!emails.has(user.user.email)) {
emails.add(user.user.email);
options.push({
label: user.user.email,
value: user.user.email,
user: user.user,
});
}
}
}
return options;
}, [users]);
const filterOptions = createFilterOptions({
trim: true,
ignoreCase: true,
matchFrom: "start",
stringify: (option: UserOptionType) => option.user.displayName || "",
});
const renderOption: AutocompleteProps<
UserOptionType,
false,
false,
false
>["renderOption"] = (props, option) => {
return <UserListItem user={option.user} {...props} />;
};
if (value === undefined || value === null) {
value = [];
} else if (!Array.isArray(value)) {
value = [value.email];
}
return (
<MultiSelect
value={value}
options={options}
label={column.name}
labelPlural={column.name}
multiple={column.config?.multiple || false}
onChange={(v: any) => {
if (typeof v === "string") {
v = [v];
}
onChange(v);
}}
disabled={disabled}
clearText="Clear"
doneText="Done"
{...{
AutocompleteProps: {
renderOption,
filterOptions,
},
}}
onClose={() => {
onSubmit();
showPopoverCell(false);
}}
// itemRenderer={(option: UserOptionType) => <UserListItem user={option.user} />}
TextFieldProps={{
style: { display: "none" },
SelectProps: {
open: open === undefined ? true : open,
MenuProps: {
anchorEl: parentRef || null,
anchorOrigin: { vertical: "bottom", horizontal: "center" },
transformOrigin: { vertical: "top", horizontal: "center" },
sx: {
"& .MuiPaper-root": { minWidth: `${column.width}px !important` },
},
},
},
}}
/>
);
}
const UserListItem = ({ user, ...props }: { user: UserDataType }) => {
return (
<li {...props}>
<Box sx={[{ position: "relative" }]}>
<Stack
spacing={0.75}
direction="row"
alignItems="center"
style={{ width: "100%" }}
>
<Avatar
alt="Avatar"
src={user.photoURL}
sx={{
width: 20,
height: 20,
fontSize: "inherit",
marginRight: "6px",
}}
>
{user.displayName ? user.displayName[0] : ""}
</Avatar>
<span>{user.displayName}</span>
</Stack>
</Box>
</li>
);
};

View File

@@ -4,14 +4,14 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable
import UserIcon from "@mui/icons-material/PersonOutlined";
import DisplayCell from "./DisplayCell";
import EditorCell from "./EditorCell";
const SideDrawerField = lazy(
() =>
import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-User" */)
);
const Settings = lazy(
() =>
import("../CreatedBy/Settings" /* webpackChunkName: "Settings-CreatedBy" */)
() => import("./Settings" /* webpackChunkName: "Settings-User" */)
);
export const config: IFieldConfig = {
@@ -23,7 +23,10 @@ export const config: IFieldConfig = {
initialValue: null,
icon: <UserIcon />,
description: "User information and optionally, timestamp. Read-only.",
TableCell: withRenderTableCell(DisplayCell, null),
TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", {
disablePadding: true,
transparentPopover: true,
}),
SideDrawerField,
settings: Settings,
};

View File

@@ -32,6 +32,7 @@ import ConnectTable from "./ConnectTable";
import ConnectService from "./ConnectService";
import Json from "./Json";
import Code from "./Code";
import Array from "./Array";
import Action from "./Action";
import Derivative from "./Derivative";
import Formula from "./Formula";
@@ -84,6 +85,7 @@ export const FIELDS: IFieldConfig[] = [
Json,
Code,
Markdown,
Array,
/** CLOUD FUNCTION */
Action,
Derivative,

View File

@@ -65,8 +65,10 @@ const WIKI_PATHS = {
webhooks: "/webhooks",
importAirtable: "/import-export-data/import-airtable",
importAirtableApiKey: "/import-export-data/import-airtable#api-key",
importAirtableTableUrl: "/import-export-data/import-airtable#table-url",
importAirtableApiKey:
"/import-export-data/import-airtable#retrieving-the-airtable-api-key",
importAirtableTableUrl:
"/import-export-data/import-airtable#obtaining-the-airtable-table-url",
cloudLogs: "/cloud-logs",
};
export const WIKI_LINKS = mapValues(

View File

@@ -36,6 +36,7 @@ export enum FieldType {
json = "JSON",
code = "CODE",
markdown = "MARKDOWN",
array = "ARRAY",
// CLOUD FUNCTION
action = "ACTION",
derivative = "DERIVATIVE",

View File

@@ -113,7 +113,12 @@ export default function AuthLayout({
}
>
{title && (
<Typography component="h1" variant="h4" align="center" sx={{ mt: -1 }}>
<Typography
component="h1"
variant="h4"
align="center"
sx={{ mt: -1 }}
>
{title}
</Typography>
)}

View File

@@ -33,6 +33,7 @@ import {
tableSchemaAtom,
columnModalAtom,
tableModalAtom,
tableSortsAtom,
} from "@src/atoms/tableScope";
import useBeforeUnload from "@src/hooks/useBeforeUnload";
import ActionParamsProvider from "@src/components/fields/Action/FormDialog/Provider";

View File

@@ -22,6 +22,10 @@ import {
AdditionalTableSettings,
MinimumTableSettings,
currentUserAtom,
updateSecretNamesAtom,
projectIdAtom,
rowyRunAtom,
secretNamesAtom,
} from "@src/atoms/projectScope";
import { firebaseDbAtom } from "./init";
@@ -34,10 +38,15 @@ import { rowyUser } from "@src/utils/table";
import { TableSettings, TableSchema, SubTablesSchema } from "@src/types/table";
import { FieldType } from "@src/constants/fields";
import { getFieldProp } from "@src/components/fields";
import { runRoutes } from "@src/constants/runRoutes";
export function useTableFunctions() {
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
const [currentUser] = useAtom(currentUserAtom, projectScope);
const [projectId] = useAtom(projectIdAtom, projectScope);
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
const [secretNames, setSecretNames] = useAtom(secretNamesAtom, projectScope);
const [updateSecretNames] = useAtom(updateSecretNamesAtom, projectScope);
// Create a function to get the latest tables from project settings,
// so we dont create new functions when tables change
@@ -330,4 +339,36 @@ export function useTableFunctions() {
return tableSchema as TableSchema;
});
}, [firebaseDb, readTables, setGetTableSchema]);
// Set the deleteTable function
const setUpdateSecretNames = useSetAtom(updateSecretNamesAtom, projectScope);
useEffect(() => {
if (!projectId || !rowyRun || !secretNamesAtom) return;
setUpdateSecretNames(() => async (clearSecretNames?: boolean) => {
setSecretNames({
loading: true,
secretNames: clearSecretNames ? null : secretNames.secretNames,
});
rowyRun({
route: runRoutes.listSecrets,
})
.then((secrets: string[]) => {
setSecretNames({
loading: false,
secretNames: secrets,
});
})
.catch((e) => {
setSecretNames({
loading: false,
secretNames: clearSecretNames ? null : secretNames.secretNames,
});
});
});
}, [projectId, rowyRun, setUpdateSecretNames]);
useEffect(() => {
if (updateSecretNames) {
updateSecretNames(true);
}
}, [updateSecretNames]);
}

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