Merge pull request #1541 from rowyio/rc

Rc
This commit is contained in:
shams-mosowi
2024-11-24 06:46:34 +11:00
committed by GitHub
33 changed files with 281 additions and 104 deletions

View File

@@ -101,7 +101,7 @@ https://user-images.githubusercontent.com/307298/157185793-f67511cd-7b7b-4229-95
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
a cloud run instance that operates exclusively on your GCP project. So we do
not access or store any of your data on Rowy.
[<img width="200" alt="Guided quick start button" src="https://user-images.githubusercontent.com/307298/185548050-e9208fb6-fe53-4c84-bbfa-53c08e03c15f.png">](https://rowy.app/)

View File

@@ -34,6 +34,7 @@
"file-saver": "^2.0.5",
"firebase": "^9.12.1",
"firebaseui": "^6.0.1",
"fuse.js": "^7.0.0",
"jotai": "^1.8.4",
"json-stable-stringify-without-jsonify": "^1.0.1",
"jszip": "^3.10.0",

View File

@@ -386,9 +386,7 @@ export const updateFieldAtom = atom(
);
if (!row) throw new Error("Could not find row");
const isLocalRow =
fieldName.startsWith("_rowy_formulaValue_") ||
Boolean(find(tableRowsLocal, ["_rowy_ref.path", path]));
const isLocalRow = Boolean(find(tableRowsLocal, ["_rowy_ref.path", path]));
const update: Partial<TableRow> = {};
@@ -469,14 +467,6 @@ export const updateFieldAtom = atom(
deleteFields: deleteField ? [fieldName] : [],
});
// TODO(han): Formula field persistence
// const config = find(tableColumnsOrdered, (c) => {
// const [, key] = fieldName.split("_rowy_formulaValue_");
// return c.key === key;
// });
// if(!config.persist) return;
if (fieldName.startsWith("_rowy_formulaValue")) return;
// If it has no missingRequiredFields, also write to db
// And write entire row to handle the case where it doesnt exist in db yet
if (missingRequiredFields.length === 0) {

View File

@@ -1,5 +1,6 @@
import MultiSelect from "@rowy/multiselect";
import { Box, ListItemIcon, Typography } from "@mui/material";
import Fuse from 'fuse.js';
import { FIELDS } from "@src/components/fields";
import { FieldType } from "@src/constants/fields";
@@ -23,6 +24,15 @@ export interface IFieldsDropdownProps {
[key: string]: any;
}
export interface OptionsType {
label: string;
value: string;
disabled: boolean;
requireCloudFunctionSetup: boolean;
requireCollectionTable: boolean;
keywords: string[];
}
/**
* Returns dropdown component of all available types
*/
@@ -52,9 +62,21 @@ export default function FieldsDropdown({
disabled: requireCloudFunctionSetup || requireCollectionTable,
requireCloudFunctionSetup,
requireCollectionTable,
keywords: fieldConfig.keywords || []
};
});
const filterOptions = (options: OptionsType[], inputConfig: any) => {
const fuse = new Fuse(options, {
keys: [{name:'label', weight: 2}, 'keywords'],
includeScore: true,
threshold: 0.4,
});
const results = fuse.search(inputConfig?.inputValue);
return results.length > 0 ? results.map((result) => result.item) : options;
}
return (
<MultiSelect
multiple={false}
@@ -80,6 +102,7 @@ export default function FieldsDropdown({
},
},
},
filterOptions
},
} as any)}
itemRenderer={(option) => (

View File

@@ -121,6 +121,7 @@ export default function Table({
const [tablePage, setTablePage] = useAtom(tablePageAtom, tableScope);
const setReactTable = useSetAtom(reactTableAtom, tableScope);
const setSelectedCell = useSetAtom(selectedCellAtom, tableScope);
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
// Get user settings and tableId for applying sort sorting
@@ -313,6 +314,8 @@ export default function Table({
const { scrollHeight, scrollTop, clientHeight } = containerElement;
if (scrollHeight - scrollTop - clientHeight < 300) {
// deselect cell on next page load
setSelectedCell(null);
setTablePage((p) => p + 1);
}
},

View File

@@ -1,29 +1,28 @@
import { useCallback, useState, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useSnackbar } from "notistack";
import { get, find } from "lodash-es";
import { find, get, isDate, isFunction } from "lodash-es";
import {
tableScope,
tableSchemaAtom,
tableRowsAtom,
updateFieldAtom,
SelectedCell,
tableRowsAtom,
tableSchemaAtom,
tableScope,
updateFieldAtom,
} from "@src/atoms/tableScope";
import { getFieldProp, getFieldType } from "@src/components/fields";
import { ColumnConfig } from "@src/types/table";
import { FieldType } from "@src/constants/fields";
import { format } from "date-fns";
import { format, parse, isValid } from "date-fns";
import { DATE_FORMAT, DATE_TIME_FORMAT } from "@src/constants/dates";
import { isDate, isFunction } from "lodash-es";
import { getDurationString } from "@src/components/fields/Duration/utils";
import { doc } from "firebase/firestore";
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
import { projectScope } from "@src/atoms/projectScope";
export const SUPPORTED_TYPES_COPY = new Set([
export const SUPPORTED_TYPES_COPY = new Set<FieldType>([
// TEXT
FieldType.shortText,
FieldType.longText,
@@ -54,17 +53,24 @@ export const SUPPORTED_TYPES_COPY = new Set([
FieldType.code,
FieldType.markdown,
FieldType.array,
// CLOUD FUNCTION
FieldType.action,
FieldType.derivative,
FieldType.status,
// AUDIT
FieldType.createdBy,
FieldType.updatedBy,
FieldType.createdAt,
FieldType.updatedAt,
// CONNECTION
FieldType.arraySubTable,
FieldType.reference,
// METADATA
FieldType.user,
FieldType.id,
]);
export const SUPPORTED_TYPES_PASTE = new Set([
export const SUPPORTED_TYPES_PASTE = new Set<FieldType>([
// TEXT
FieldType.shortText,
FieldType.longText,
@@ -72,17 +78,34 @@ export const SUPPORTED_TYPES_PASTE = new Set([
FieldType.email,
FieldType.phone,
FieldType.url,
// SELECT
FieldType.singleSelect,
FieldType.multiSelect,
// NUMERIC
FieldType.checkbox,
FieldType.number,
FieldType.percentage,
FieldType.rating,
FieldType.slider,
FieldType.color,
FieldType.geoPoint,
// DATE & TIME
FieldType.date,
FieldType.dateTime,
FieldType.duration,
// FILE
FieldType.image,
FieldType.file,
// CODE
FieldType.json,
FieldType.code,
FieldType.markdown,
FieldType.array,
// CONNECTION
FieldType.arraySubTable,
FieldType.reference,
// METADATA
FieldType.user,
]);
export function useMenuAction(
@@ -163,93 +186,177 @@ export function useMenuAction(
const handlePaste = useCallback(
async (e?: ClipboardEvent) => {
try {
if (!selectedCell || !selectedCol) return;
if (!selectedCell || !selectedCol) return;
// checks which element has focus, if it is not the gridcell it won't paste the copied content inside the gridcell
if (document.activeElement?.role !== "gridcell") return;
// if the focus element is not gridcell or menuitem (click on paste menu action)
// it won't paste the copied content inside the gridcell
if (
!["gridcell", "menuitem"].includes(document.activeElement?.role ?? "")
)
return;
let text: string;
// prevent from pasting inside array subtable overwrites the whole object
if (
document.activeElement
?.getAttribute?.("data-row-id")
?.startsWith("subtable-array") &&
selectedCell.columnKey !==
document.activeElement?.getAttribute?.("data-col-id")
) {
return;
}
let clipboardText: string;
if (navigator.userAgent.includes("Firefox")) {
// Firefox doesn't allow for reading clipboard data, hence the workaround
if (navigator.userAgent.includes("Firefox")) {
if (!e || !e.clipboardData) {
enqueueSnackbar(
`If you're on Firefox, please use the hotkey instead (Ctrl + V / Cmd + V).`,
{
variant: "info",
autoHideDuration: 7000,
}
);
enqueueSnackbar(`Cannot read clipboard data.`, {
variant: "error",
});
return;
}
text = e.clipboardData.getData("text/plain") || "";
} else {
try {
text = await navigator.clipboard.readText();
} catch (e) {
enqueueSnackbar(`Read clipboard permission denied.`, {
variant: "error",
});
return;
}
if (!e || !e.clipboardData) {
enqueueSnackbar(
`If you're on Firefox, please use the hotkey instead (Ctrl + V / Cmd + V).`,
{
variant: "info",
autoHideDuration: 7000,
}
);
enqueueSnackbar(`Cannot read clipboard data.`, {
variant: "error",
});
return;
}
clipboardText = e.clipboardData.getData("text/plain") || "";
} else {
try {
clipboardText = await navigator.clipboard.readText();
} catch (e) {
enqueueSnackbar(`Read clipboard permission denied.`, {
variant: "error",
});
return;
}
}
try {
let parsedValue;
const cellDataType = getFieldProp(
"dataType",
getFieldType(selectedCol)
);
let parsed;
switch (cellDataType) {
case "number":
parsed = Number(text);
if (isNaN(parsed)) throw new Error(`${text} is not a number`);
// parse value first by type if matches, then by column type
switch (selectedCol.type) {
case FieldType.percentage:
clipboardText = clipboardText.trim();
if (clipboardText.endsWith("%")) {
clipboardText = clipboardText.slice(0, -1);
parsedValue = Number(clipboardText) / 100;
} else {
parsedValue = Number(clipboardText);
}
if (isNaN(parsedValue))
throw new Error(`${clipboardText} is not a percentage`);
break;
case "string":
parsed = text;
case FieldType.date:
parsedValue = parse(
clipboardText,
selectedCol.config?.format || DATE_FORMAT,
new Date()
);
if (!isValid(parsedValue)) {
parsedValue = parse(clipboardText, DATE_FORMAT, new Date());
}
if (!isValid(parsedValue)) {
parsedValue = new Date(clipboardText);
}
if (!isValid(parsedValue)) {
throw new Error(`${clipboardText} is not a date`);
}
break;
case "reference":
case FieldType.dateTime:
parsedValue = parse(
clipboardText,
selectedCol.config?.format || DATE_TIME_FORMAT,
new Date()
);
if (!isValid(parsedValue)) {
parsedValue = parse(clipboardText, DATE_TIME_FORMAT, new Date());
}
if (!isValid(parsedValue)) {
parsedValue = new Date(clipboardText);
}
if (!isValid(parsedValue)) {
throw new Error(`${clipboardText} is not a date`);
}
break;
case FieldType.duration:
try {
parsed = doc(firebaseDb, text);
const json = JSON.parse(clipboardText);
parsedValue = {
start: new Date(json.start),
end: new Date(json.end),
};
} catch (e: any) {
enqueueSnackbar(`Invalid reference.`, { variant: "error" });
throw new Error(
`${clipboardText} does not have valida start and end dates`
);
}
break;
case FieldType.arraySubTable:
try {
parsedValue = JSON.parse(clipboardText);
} catch (e: any) {
throw new Error(`${clipboardText} is not valid array subtable`);
}
if (!Array.isArray(parsedValue)) {
throw new Error(`${clipboardText} is not an array`);
}
break;
default:
parsed = JSON.parse(text);
break;
switch (cellDataType) {
case "number":
parsedValue = Number(clipboardText);
if (isNaN(parsedValue))
throw new Error(`${clipboardText} is not a number`);
break;
case "string":
parsedValue = clipboardText;
break;
case "reference":
try {
parsedValue = doc(firebaseDb, clipboardText);
} catch (e: any) {
enqueueSnackbar(`Invalid reference.`, { variant: "error" });
}
break;
default:
parsedValue = JSON.parse(clipboardText);
break;
}
}
// post process parsed values
if (selectedCol.type === FieldType.slider) {
if (parsed < selectedCol.config?.min)
parsed = selectedCol.config?.min;
else if (parsed > selectedCol.config?.max)
parsed = selectedCol.config?.max;
if (parsedValue < selectedCol.config?.min)
parsedValue = selectedCol.config?.min;
else if (parsedValue > (selectedCol.config?.max || 10))
parsedValue = selectedCol.config?.max || 10;
}
if (selectedCol.type === FieldType.rating) {
if (parsed < 0) parsed = 0;
if (parsed > (selectedCol.config?.max || 5))
parsed = selectedCol.config?.max || 5;
if (parsedValue < 0) parsedValue = 0;
if (parsedValue > (selectedCol.config?.max || 5))
parsedValue = selectedCol.config?.max || 5;
}
if (selectedCol.type === FieldType.percentage) {
parsed = parsed / 100;
}
updateField({
path: selectedCell.path,
fieldName: selectedCol.fieldName,
value: parsed,
value: parsedValue,
arrayTableData: {
index: selectedCell.arrayIndex,
},
});
} catch (error) {
enqueueSnackbar(
`${selectedCol?.type} field does not support the data type being pasted`,
{ variant: "error" }
);
enqueueSnackbar(`Paste error on ${selectedCol?.type}: ${error}`, {
variant: "error",
});
}
if (handleClose) handleClose();
},
@@ -286,7 +393,7 @@ export function useMenuAction(
if (SUPPORTED_TYPES_COPY.has(fieldType)) {
return func();
} else {
enqueueSnackbar(`${fieldType} field cannot be copied`, {
enqueueSnackbar(`${fieldType} cannot be copied`, {
variant: "error",
});
}
@@ -309,12 +416,9 @@ export function useMenuAction(
if (SUPPORTED_TYPES_PASTE.has(fieldType)) {
return func(e);
} else {
enqueueSnackbar(
`${fieldType} field does not support paste functionality`,
{
variant: "error",
}
);
enqueueSnackbar(`${fieldType} does not support paste`, {
variant: "error",
});
}
};
},
@@ -324,11 +428,17 @@ export function useMenuAction(
const getValue = useCallback(
(cellValue: any) => {
switch (selectedCol?.type) {
case FieldType.percentage:
return cellValue * 100;
case FieldType.multiSelect:
case FieldType.json:
case FieldType.color:
case FieldType.geoPoint:
case FieldType.image:
case FieldType.file:
case FieldType.array:
case FieldType.arraySubTable:
case FieldType.createdBy:
case FieldType.updatedBy:
case FieldType.user:
return JSON.stringify(cellValue);
case FieldType.date:
if (
@@ -362,19 +472,23 @@ export function useMenuAction(
}
}
return;
case FieldType.percentage:
return `${cellValue * 100}%`;
case FieldType.duration:
return getDurationString(
cellValue.start.toDate(),
cellValue.end.toDate()
);
case FieldType.image:
case FieldType.file:
return cellValue[0].downloadURL;
case FieldType.createdBy:
case FieldType.updatedBy:
return cellValue.displayName;
return JSON.stringify({
duration: getDurationString(
cellValue.start.toDate(),
cellValue.end.toDate()
),
start: cellValue.start.toDate(),
end: cellValue.end.toDate(),
});
case FieldType.action:
return cellValue.status || "";
case FieldType.reference:
return cellValue.path;
case FieldType.formula:
return cellValue.formula || "";
default:
return cellValue;
}

View File

@@ -31,5 +31,6 @@ export const config: IFieldConfig = {
filter: { operators, defaultValue: [] },
requireConfiguration: false,
contextMenuActions: BasicContextMenuActions,
keywords: ["list"]
};
export default config;

View File

@@ -45,5 +45,6 @@ export const config: IFieldConfig = {
},
SideDrawerField,
contextMenuActions: BasicContextMenuActions,
keywords: ["boolean", "switch", "true", "false", "on", "off"]
};
export default config;

View File

@@ -34,5 +34,6 @@ export const config: IFieldConfig = {
SideDrawerField,
settings: Settings,
contextMenuActions: BasicContextMenuActions,
keywords: ["snippet", "block"]
};
export default config;

View File

@@ -44,5 +44,6 @@ export const config: IFieldConfig = {
}
},
contextMenuActions: BasicContextMenuActions,
keywords: ["hexcode"]
};
export default config;

View File

@@ -30,5 +30,6 @@ export const config: IFieldConfig = {
settings: Settings,
requireCollectionTable: true,
contextMenuActions: BasicContextMenuActions,
keywords: ["date", "time"]
};
export default config;

View File

@@ -31,5 +31,6 @@ export const config: IFieldConfig = {
settings: Settings,
requireCollectionTable: true,
contextMenuActions: BasicContextMenuActions,
keywords: ["date", "time"]
};
export default config;

View File

@@ -6,9 +6,7 @@ import {
_deleteRowDbAtom,
_updateRowDbAtom,
tableNextPageAtom,
tableRowsAtom,
tableRowsDbAtom,
tableRowsLocalAtom,
tableScope,
tableSettingsAtom,
} from "@src/atoms/tableScope";

View File

@@ -27,5 +27,6 @@ export const config: IFieldConfig = {
settings: Settings,
settingsValidator: settingsValidator,
requireConfiguration: true,
keywords: ["equation"]
};
export default config;

View File

@@ -26,5 +26,6 @@ export const config: IFieldConfig = {
}),
SideDrawerField,
contextMenuActions: BasicContextMenuActions,
keywords: ["location", "latitude", "longitude", "point"]
};
export default config;

View File

@@ -19,5 +19,6 @@ export const config: IFieldConfig = {
description: "Displays the rows ID. Read-only. Cannot be sorted.",
TableCell: withRenderTableCell(DisplayCell, null),
SideDrawerField,
keywords: ["unique"]
};
export default config;

View File

@@ -28,6 +28,7 @@ export const config: IFieldConfig = {
}),
SideDrawerField,
contextMenuActions: ContextMenuActions,
keywords: ["picture"]
};
export default config;

View File

@@ -35,5 +35,6 @@ export const config: IFieldConfig = {
filter: {
operators: filterOperators,
},
keywords: ["string"]
};
export default config;

View File

@@ -25,5 +25,6 @@ export const config: IFieldConfig = {
TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover"),
SideDrawerField,
contextMenuActions: BasicContextMenuActions,
keywords: ["md"]
};
export default config;

View File

@@ -50,5 +50,6 @@ export const config: IFieldConfig = {
operators: filterOperators,
},
contextMenuActions: BasicContextMenuActions,
keywords: ["options"]
};
export default config;

View File

@@ -35,5 +35,6 @@ export const config: IFieldConfig = {
return null;
}
},
keywords: ["digit"]
};
export default config;

View File

@@ -28,5 +28,6 @@ export const config: IFieldConfig = {
filter: {
operators: filterOperators,
},
keywords: ["number", "contact"]
};
export default config;

View File

@@ -46,5 +46,6 @@ export const config: IFieldConfig = {
}
},
contextMenuActions: BasicContextMenuActions,
keywords: ["star"]
};
export default config;

View File

@@ -25,5 +25,6 @@ export const config: IFieldConfig = {
contextMenuActions: BasicContextMenuActions,
TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover"),
SideDrawerField,
keywords: ["string"]
};
export default config;

View File

@@ -36,5 +36,6 @@ export const config: IFieldConfig = {
filter: {
operators: filterOperators,
},
keywords: ["string"]
};
export default config;

View File

@@ -37,5 +37,6 @@ export const config: IFieldConfig = {
filter: { operators: filterOperators },
requireConfiguration: true,
contextMenuActions: BasicContextMenuActions,
keywords: ["options"]
};
export default config;

View File

@@ -31,5 +31,6 @@ export const config: IFieldConfig = {
settings: Settings,
requireCollectionTable: true,
contextMenuActions: BasicContextMenuActions,
keywords: ["date", "time"]
};
export default config;

View File

@@ -33,5 +33,6 @@ export const config: IFieldConfig = {
settings: Settings,
requireCollectionTable: true,
contextMenuActions: BasicContextMenuActions,
keywords: ["date", "time"]
};
export default config;

View File

@@ -30,5 +30,6 @@ export const config: IFieldConfig = {
filter: {
operators: filterOperators,
},
keywords: ["link", "path"]
};
export default config;

View File

@@ -29,5 +29,6 @@ export const config: IFieldConfig = {
}),
SideDrawerField,
settings: Settings,
keywords: ["entity"]
};
export default config;

View File

@@ -42,6 +42,7 @@ export interface IFieldConfig {
sortKey?: string;
csvExportFormatter?: (value: any, config?: any) => string;
csvImportParser?: (value: string, config?: any) => any;
keywords?: string[];
}
/** See {@link IRenderedTableCellProps | `withRenderTableCell` } for guidance */

View File

@@ -26,6 +26,7 @@ import {
DocumentData,
or,
QueryFieldFilterConstraint,
Timestamp,
} from "firebase/firestore";
import { useErrorHandler } from "react-error-boundary";
@@ -402,6 +403,26 @@ const getQuery = <T>(
}
};
/**
* Parse datetime to Date object
* \{ nanoseconds: number; seconds: number \} is a Timestamp object without toDate() method, we need to calculate it manually
* */
const parseDateFilterValue = (
date: Date | Timestamp | { nanoseconds: number; seconds: number }
) => {
if (date instanceof Date) {
return date;
} else if ("toDate" in date) {
return date.toDate();
} else if (date.seconds) {
return new Date(date.seconds * 1000 + date.nanoseconds / 1_000_000);
} else if (date instanceof Timestamp) {
return date.toDate();
} else {
throw new Error(`Invalid date ${date}`);
}
};
/**
* Support custom filter operators not supported by Firestore.
* e.g. date-range-equal: `>=` && `<=` operators when `==` is used on dates.
@@ -414,8 +435,7 @@ export const tableFiltersToFirestoreFilters = (filters: TableFilter[]) => {
for (const filter of filters) {
if (filter.operator.startsWith("date-")) {
if (!filter.value) continue;
const filterDate =
"toDate" in filter.value ? filter.value.toDate() : filter.value;
const filterDate = parseDateFilterValue(filter.value);
const [startDate, endDate] = getDateRange(filterDate);
if (filter.operator === "date-equal") {
@@ -433,8 +453,7 @@ export const tableFiltersToFirestoreFilters = (filters: TableFilter[]) => {
continue;
} else if (filter.operator === "time-minute-equal") {
if (!filter.value) continue;
const filterDate =
"toDate" in filter.value ? filter.value.toDate() : filter.value;
const filterDate = parseDateFilterValue(filter.value);
const [startDate, endDate] = getTimeRange(filterDate);
firestoreFilters.push(where(filter.key, ">=", startDate));

View File

@@ -5208,6 +5208,11 @@ functions-have-names@^1.2.2:
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
fuse.js@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2"
integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==
gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"