mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
4
.github/workflows/deploy-preview.yml
vendored
4
.github/workflows/deploy-preview.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
env:
|
||||
REACT_APP_FIREBASE_PROJECT_ID: rowyio
|
||||
REACT_APP_FIREBASE_PROJECT_WEB_API_KEY:
|
||||
"${{ secrets.FIREBASE_WEB_API_KEY_ROWYIO }}"
|
||||
"${{ secrets.FIREBASE_WEB_API_KEY_TRYROWY }}"
|
||||
CI: ""
|
||||
jobs:
|
||||
build_and_preview:
|
||||
@@ -27,6 +27,6 @@ jobs:
|
||||
with:
|
||||
repoToken: "${{ secrets.GITHUB_TOKEN }}"
|
||||
firebaseServiceAccount:
|
||||
"${{ secrets.FIREBASE_SERVICE_ACCOUNT_ROWYIO }}"
|
||||
"${{ secrets.FIREBASE_SERVICE_ACCOUNT_TRYROWY }}"
|
||||
expires: 14d
|
||||
projectId: rowyio
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
"@mui/icons-material": "5.6.0",
|
||||
"@mui/lab": "^5.0.0-alpha.76",
|
||||
"@mui/material": "5.6.0",
|
||||
"@mui/styles": "5.6.0",
|
||||
"@mui/x-date-pickers": "^5.0.0-alpha.4",
|
||||
"@rowy/form-builder": "^0.6.2",
|
||||
"@rowy/multiselect": "^0.4.0",
|
||||
"@rowy/form-builder": "^0.7.0",
|
||||
"@rowy/multiselect": "^0.4.1",
|
||||
"@tinymce/tinymce-react": "^3",
|
||||
"@uiw/react-md-editor": "^3.14.1",
|
||||
"algoliasearch": "^4.13.1",
|
||||
@@ -29,7 +28,7 @@
|
||||
"date-fns": "^2.28.0",
|
||||
"dompurify": "^2.3.6",
|
||||
"file-saver": "^2.0.5",
|
||||
"firebase": "^9.6.11",
|
||||
"firebase": "^9.12.1",
|
||||
"firebaseui": "^6.0.1",
|
||||
"jotai": "^1.8.4",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
@@ -48,6 +47,7 @@
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-color-palette": "^6.2.0",
|
||||
"react-data-grid": "7.0.0-beta.5",
|
||||
"react-detect-offline": "^2.4.5",
|
||||
"react-div-100vh": "^0.7.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
@@ -157,6 +157,7 @@
|
||||
"@types/node": "^17.0.23",
|
||||
"@types/react": "^18.0.5",
|
||||
"@types/react-beautiful-dnd": "^13.1.2",
|
||||
"@types/react-detect-offline": "^2.4.1",
|
||||
"@types/react-div-100vh": "^0.4.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@@ -58,7 +58,7 @@ const ProjectSettingsPage = lazy(() => import("@src/pages/Settings/ProjectSettin
|
||||
// prettier-ignore
|
||||
const MembersPage = lazy(() => import("@src/pages/Settings/MembersPage" /* webpackChunkName: "MembersPage" */));
|
||||
// prettier-ignore
|
||||
const DebugSettingsPage = lazy(() => import("@src/pages/Settings/DebugSettingsPage" /* webpackChunkName: "DebugSettingsPage" */));
|
||||
const DebugPage = lazy(() => import("@src/pages/Settings/DebugPage" /* webpackChunkName: "DebugPage" */));
|
||||
|
||||
export default function App() {
|
||||
const [currentUser] = useAtom(currentUserAtom, projectScope);
|
||||
@@ -153,14 +153,8 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
<Route path={ROUTES.members} element={<MembersPage />} />
|
||||
<Route
|
||||
path={ROUTES.debugSettings}
|
||||
element={
|
||||
<AdminRoute>
|
||||
<DebugSettingsPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path={ROUTES.debug} element={<DebugPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { atom } from "jotai";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { ThemeOptions } from "@mui/material";
|
||||
|
||||
import { userRolesAtom } from "./auth";
|
||||
import { UserSettings } from "./user";
|
||||
import {
|
||||
PublicSettings,
|
||||
ProjectSettings,
|
||||
UserSettings,
|
||||
} from "@src/types/settings";
|
||||
import {
|
||||
UpdateDocFunction,
|
||||
UpdateCollectionDocFunction,
|
||||
@@ -15,22 +18,6 @@ import { FunctionSettings } from "@src/types/function";
|
||||
|
||||
export const projectIdAtom = atom<string>("");
|
||||
|
||||
/** Public settings are visible to unauthenticated users */
|
||||
export type PublicSettings = Partial<{
|
||||
signInOptions: Array<
|
||||
| "google"
|
||||
| "twitter"
|
||||
| "facebook"
|
||||
| "github"
|
||||
| "microsoft"
|
||||
| "apple"
|
||||
| "yahoo"
|
||||
| "email"
|
||||
| "phone"
|
||||
| "anonymous"
|
||||
>;
|
||||
theme: Record<"base" | "light" | "dark", ThemeOptions>;
|
||||
}>;
|
||||
/** Public settings are visible to unauthenticated users */
|
||||
export const publicSettingsAtom = atom<PublicSettings>({});
|
||||
/** Stores a function that updates public settings */
|
||||
@@ -38,21 +25,6 @@ export const updatePublicSettingsAtom = atom<
|
||||
UpdateDocFunction<PublicSettings> | undefined
|
||||
>(undefined);
|
||||
|
||||
/** Project settings are visible to authenticated users */
|
||||
export type ProjectSettings = Partial<{
|
||||
tables: TableSettings[];
|
||||
|
||||
setupCompleted: boolean;
|
||||
|
||||
rowyRunUrl: string;
|
||||
rowyRunRegion: string;
|
||||
rowyRunDeployStatus: "BUILDING" | "COMPLETE";
|
||||
services: Partial<{
|
||||
hooks: string;
|
||||
builder: string;
|
||||
terminal: string;
|
||||
}>;
|
||||
}>;
|
||||
/** Project settings are visible to authenticated users */
|
||||
export const projectSettingsAtom = atom<ProjectSettings>({});
|
||||
/**
|
||||
@@ -74,10 +46,10 @@ export const tablesAtom = atom<TableSettings[]>((get) => {
|
||||
const tables = get(projectSettingsAtom).tables || [];
|
||||
|
||||
return sortBy(tables, "name")
|
||||
.filter(
|
||||
(table) =>
|
||||
userRoles.includes("ADMIN") ||
|
||||
table.roles.some((role) => userRoles.includes(role))
|
||||
.filter((table) =>
|
||||
userRoles.includes("ADMIN") || Array.isArray(table.roles)
|
||||
? table.roles.some((role) => userRoles.includes(role))
|
||||
: false
|
||||
)
|
||||
.map((table) => ({
|
||||
...table,
|
||||
|
||||
@@ -59,9 +59,8 @@ export type ConfirmDialogProps = {
|
||||
*/
|
||||
export const confirmDialogAtom = atom(
|
||||
{ open: false } as ConfirmDialogProps,
|
||||
(get, set, update: Partial<ConfirmDialogProps>) => {
|
||||
(_, set, update: Partial<ConfirmDialogProps>) => {
|
||||
set(confirmDialogAtom, {
|
||||
...get(confirmDialogAtom),
|
||||
open: true, // Don’t require this to be set explicitly
|
||||
...update,
|
||||
});
|
||||
|
||||
@@ -1,45 +1,12 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { merge } from "lodash-es";
|
||||
import { ThemeOptions } from "@mui/material";
|
||||
|
||||
import themes from "@src/theme";
|
||||
import { publicSettingsAtom } from "./project";
|
||||
import {
|
||||
UpdateDocFunction,
|
||||
TableFilter,
|
||||
TableRowRef,
|
||||
TableSort,
|
||||
} from "@src/types/table";
|
||||
import { UserSettings } from "@src/types/settings";
|
||||
import { UpdateDocFunction } from "@src/types/table";
|
||||
|
||||
/** User info and settings */
|
||||
export type UserSettings = Partial<{
|
||||
_rowy_ref: TableRowRef;
|
||||
/** Synced from user auth info */
|
||||
user: {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
photoURL?: string;
|
||||
phoneNumber?: string;
|
||||
};
|
||||
roles: string[];
|
||||
|
||||
theme: Record<"base" | "light" | "dark", ThemeOptions>;
|
||||
|
||||
favoriteTables: string[];
|
||||
/** Stores user overrides */
|
||||
tables: Record<
|
||||
string,
|
||||
Partial<{
|
||||
filters: TableFilter[];
|
||||
hiddenFields: string[];
|
||||
sorts: TableSort[];
|
||||
}>
|
||||
>;
|
||||
|
||||
/** Stores table tutorial completion */
|
||||
tableTutorialComplete?: boolean;
|
||||
}>;
|
||||
/** User info and settings */
|
||||
export const userSettingsAtom = atom<UserSettings>({});
|
||||
/** Stores a function that updates user settings */
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { atom } from "jotai";
|
||||
import { findIndex } from "lodash-es";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
|
||||
import {
|
||||
tableColumnsOrderedAtom,
|
||||
tableColumnsReducer,
|
||||
updateTableSchemaAtom,
|
||||
tableSchemaAtom,
|
||||
} from "./table";
|
||||
import { ColumnConfig } from "@src/types/table";
|
||||
|
||||
@@ -14,6 +16,7 @@ export interface IAddColumnOptions {
|
||||
/** Index to add column at. If undefined, adds to end */
|
||||
index?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set function adds a column to tableSchema, to the end or by index.
|
||||
* Also fixes any issues with column indexes, so they go from 0 to length - 1
|
||||
@@ -52,6 +55,7 @@ export interface IUpdateColumnOptions {
|
||||
/** If passed, reorders the column to the index */
|
||||
index?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set function updates a column in tableSchema
|
||||
* @throws Error if column not found
|
||||
@@ -75,7 +79,7 @@ export const updateColumnAtom = atom(
|
||||
throw new Error(`Column with key "${key}" not found`);
|
||||
|
||||
// If column is not being reordered, just update the config
|
||||
if (!index) {
|
||||
if (index === undefined) {
|
||||
tableColumnsOrdered[currentIndex] = {
|
||||
...tableColumnsOrdered[currentIndex],
|
||||
...config,
|
||||
@@ -93,6 +97,8 @@ export const updateColumnAtom = atom(
|
||||
});
|
||||
}
|
||||
|
||||
console.log(tableColumnsOrdered);
|
||||
|
||||
// Reduce array into single object with updated indexes
|
||||
const updatedColumns = tableColumnsOrdered.reduce(tableColumnsReducer, {});
|
||||
await updateTableSchema({ columns: updatedColumns });
|
||||
@@ -110,13 +116,50 @@ export const updateColumnAtom = atom(
|
||||
* ```
|
||||
*/
|
||||
export const deleteColumnAtom = atom(null, async (get, _set, key: string) => {
|
||||
const tableSchema = get(tableSchemaAtom);
|
||||
const tableColumnsOrdered = [...get(tableColumnsOrderedAtom)];
|
||||
const updateTableSchema = get(updateTableSchemaAtom);
|
||||
if (!updateTableSchema) throw new Error("Cannot update table schema");
|
||||
|
||||
const updatedColumns = tableColumnsOrdered
|
||||
.filter((c) => c.key !== key)
|
||||
.map((c) => {
|
||||
// remove column from derivatives listener fields
|
||||
if (c.type === FieldType.derivative) {
|
||||
return {
|
||||
...c,
|
||||
config: {
|
||||
...c.config,
|
||||
listenerFields:
|
||||
c.config?.listenerFields?.filter((f) => f !== key) ?? [],
|
||||
},
|
||||
};
|
||||
} else if (c.type === FieldType.action) {
|
||||
return {
|
||||
...c,
|
||||
config: {
|
||||
...c.config,
|
||||
requiredFields:
|
||||
c.config?.requiredFields?.filter((f) => f !== key) ?? [],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return c;
|
||||
}
|
||||
})
|
||||
.reduce(tableColumnsReducer, {});
|
||||
|
||||
await updateTableSchema({ columns: updatedColumns }, [`columns.${key}`]);
|
||||
const updatedExtensionObjects = tableSchema?.extensionObjects?.map(
|
||||
(extension) => {
|
||||
return {
|
||||
...extension,
|
||||
requiredFields: extension.requiredFields.filter((f) => f !== key),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
await updateTableSchema(
|
||||
{ columns: updatedColumns, extensionObjects: updatedExtensionObjects },
|
||||
[`columns.${key}`]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
set as _set,
|
||||
isEqual,
|
||||
unset,
|
||||
filter,
|
||||
} from "lodash-es";
|
||||
|
||||
import { currentUserAtom } from "@src/atoms/projectScope";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
updateRowData,
|
||||
omitRowyFields,
|
||||
} from "@src/utils/table";
|
||||
import { arrayRemove, arrayUnion } from "firebase/firestore";
|
||||
|
||||
export interface IAddRowOptions {
|
||||
/** The row or array of rows to add */
|
||||
@@ -306,6 +308,10 @@ export interface IUpdateFieldOptions {
|
||||
ignoreRequiredFields?: boolean;
|
||||
/** Optionally, disable checking if the updated value is equal to the current value. By default, we skip the update if they’re equal. */
|
||||
disableCheckEquality?: boolean;
|
||||
/** Optionally, uses firestore's arrayUnion with the given value. Appends given value items to the existing array */
|
||||
useArrayUnion?: boolean;
|
||||
/** Optionally, uses firestore's arrayRemove with the given value. Removes given value items from the existing array */
|
||||
useArrayRemove?: boolean;
|
||||
}
|
||||
/**
|
||||
* Set function updates or deletes a field in a row.
|
||||
@@ -331,6 +337,8 @@ export const updateFieldAtom = atom(
|
||||
deleteField,
|
||||
ignoreRequiredFields,
|
||||
disableCheckEquality,
|
||||
useArrayUnion,
|
||||
useArrayRemove,
|
||||
}: IUpdateFieldOptions
|
||||
) => {
|
||||
const updateRowDb = get(_updateRowDbAtom);
|
||||
@@ -367,8 +375,36 @@ export const updateFieldAtom = atom(
|
||||
_set(update, fieldName, value);
|
||||
}
|
||||
|
||||
const localUpdate = cloneDeep(update);
|
||||
const dbUpdate = cloneDeep(update);
|
||||
// apply arrayUnion
|
||||
if (useArrayUnion) {
|
||||
if (!Array.isArray(update[fieldName]))
|
||||
throw new Error("Field must be an array");
|
||||
|
||||
// use basic array merge on local row value
|
||||
localUpdate[fieldName] = [
|
||||
...(row[fieldName] ?? []),
|
||||
...localUpdate[fieldName],
|
||||
];
|
||||
dbUpdate[fieldName] = arrayUnion(...dbUpdate[fieldName]);
|
||||
}
|
||||
|
||||
//apply arrayRemove
|
||||
if (useArrayRemove) {
|
||||
if (!Array.isArray(update[fieldName]))
|
||||
throw new Error("Field must be an array");
|
||||
|
||||
// use basic array filter on local row value
|
||||
localUpdate[fieldName] = filter(
|
||||
row[fieldName] ?? [],
|
||||
(el) => !find(localUpdate[fieldName], el)
|
||||
);
|
||||
dbUpdate[fieldName] = arrayRemove(...dbUpdate[fieldName]);
|
||||
}
|
||||
|
||||
// Check for required fields
|
||||
const newRowValues = updateRowData(cloneDeep(row), update);
|
||||
const newRowValues = updateRowData(cloneDeep(row), dbUpdate);
|
||||
const requiredFields = ignoreRequiredFields
|
||||
? []
|
||||
: tableColumnsOrdered
|
||||
@@ -383,7 +419,7 @@ export const updateFieldAtom = atom(
|
||||
set(tableRowsLocalAtom, {
|
||||
type: "update",
|
||||
path,
|
||||
row: update,
|
||||
row: localUpdate,
|
||||
deleteFields: deleteField ? [fieldName] : [],
|
||||
});
|
||||
|
||||
@@ -403,7 +439,7 @@ export const updateFieldAtom = atom(
|
||||
else {
|
||||
await updateRowDb(
|
||||
row._rowy_ref.path,
|
||||
omitRowyFields(update),
|
||||
omitRowyFields(dbUpdate),
|
||||
deleteField ? [fieldName] : []
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,3 +238,6 @@ export type AuditChangeFunction = (
|
||||
* @param data - Optional additional data to log
|
||||
*/
|
||||
export const auditChangeAtom = atom<AuditChangeFunction | undefined>(undefined);
|
||||
|
||||
/** Store total number of rows in firestore collection */
|
||||
export const serverDocCountAtom = atom(0)
|
||||
@@ -121,6 +121,12 @@ export const importAirtableAtom = atom<{
|
||||
tableId: string;
|
||||
}>({ airtableData: null, apiKey: "", baseId: "", tableId: "" });
|
||||
|
||||
/** Store side drawer open state */
|
||||
export const sideDrawerAtom = atomWithHash<"table-information" | null>(
|
||||
"sideDrawer",
|
||||
null,
|
||||
{ replaceState: true }
|
||||
);
|
||||
/** Store side drawer open state */
|
||||
export const sideDrawerOpenAtom = atom(false);
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useAtomsDebugValue } from "jotai/devtools";
|
||||
import useMemoValue from "use-memo-value";
|
||||
import { isEqual } from "lodash-es";
|
||||
|
||||
export function DebugAtoms(
|
||||
options: NonNullable<Parameters<typeof useAtomsDebugValue>[0]>
|
||||
@@ -6,3 +10,26 @@ export function DebugAtoms(
|
||||
useAtomsDebugValue(options);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an atom’s value when the `value` prop changes.
|
||||
* Useful when setting an atom’s initialValue and you want to keep it in sync.
|
||||
*/
|
||||
export function SyncAtomValue<T>({
|
||||
atom,
|
||||
scope,
|
||||
value,
|
||||
}: {
|
||||
atom: Parameters<typeof useSetAtom>[0];
|
||||
scope: Parameters<typeof useSetAtom>[1];
|
||||
value: T;
|
||||
}) {
|
||||
const memoized = useMemoValue(value, isEqual);
|
||||
const setAtom = useSetAtom(atom, scope);
|
||||
|
||||
useEffect(() => {
|
||||
setAtom(memoized);
|
||||
}, [setAtom, memoized]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ export default function CircularProgressTimed({
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: size * 0.33 * 0.5,
|
||||
width: size * 0.67,
|
||||
height: size * 0.67,
|
||||
|
||||
"& .tick": {
|
||||
stroke: (theme) => theme.palette.success.main,
|
||||
|
||||
@@ -15,6 +15,8 @@ import useMonacoCustomizations, {
|
||||
} from "./useMonacoCustomizations";
|
||||
import FullScreenButton from "@src/components/FullScreenButton";
|
||||
import { spreadSx } from "@src/utils/ui";
|
||||
import githubLightTheme from "@src/components/CodeEditor/github-light-default.json";
|
||||
import githubDarkTheme from "@src/components/CodeEditor/github-dark-default.json";
|
||||
|
||||
export interface IDiffEditorProps
|
||||
extends Partial<DiffEditorProps>,
|
||||
@@ -73,7 +75,12 @@ export default function DiffEditor({
|
||||
loading={<CircularProgressOptical size={20} sx={{ m: 2 }} />}
|
||||
className="editor"
|
||||
{...props}
|
||||
beforeMount={(monaco) => {
|
||||
monaco.editor.defineTheme("github-light", githubLightTheme as any);
|
||||
monaco.editor.defineTheme("github-dark", githubDarkTheme as any);
|
||||
}}
|
||||
onMount={handleEditorMount}
|
||||
theme={`github-${theme.palette.mode}`}
|
||||
options={
|
||||
{
|
||||
readOnly: disabled,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import FilterIcon from "@mui/icons-material/FilterList";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
@@ -50,12 +51,18 @@ import {
|
||||
columnModalAtom,
|
||||
tableFiltersPopoverAtom,
|
||||
tableNextPageAtom,
|
||||
tableSchemaAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
import { getFieldProp } from "@src/components/fields";
|
||||
import { analytics, logEvent } from "@src/analytics";
|
||||
import { formatSubTableName, getTableSchemaPath } from "@src/utils/table";
|
||||
import {
|
||||
formatSubTableName,
|
||||
getTableBuildFunctionPathname,
|
||||
getTableSchemaPath,
|
||||
} from "@src/utils/table";
|
||||
import { runRoutes } from "@src/constants/runRoutes";
|
||||
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
|
||||
|
||||
export interface IMenuModalProps {
|
||||
name: string;
|
||||
@@ -91,6 +98,8 @@ export default function ColumnMenu() {
|
||||
tableScope
|
||||
);
|
||||
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
const snackLogContext = useSnackLogContext();
|
||||
|
||||
const [altPress] = useAtom(altPressAtom, projectScope);
|
||||
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
|
||||
@@ -117,8 +126,42 @@ export default function ColumnMenu() {
|
||||
const userDocHiddenFields =
|
||||
userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? [];
|
||||
|
||||
let referencedColumns: string[] = [];
|
||||
let referencedExtensions: string[] = [];
|
||||
Object.entries(tableSchema?.columns ?? {}).forEach(([key, c], index) => {
|
||||
if (
|
||||
c.config?.listenerFields?.includes(column.key) ||
|
||||
c.config?.requiredFields?.includes(column.key)
|
||||
) {
|
||||
referencedColumns.push(c.name);
|
||||
}
|
||||
});
|
||||
tableSchema?.extensionObjects?.forEach((extension) => {
|
||||
if (extension.requiredFields.includes(column.key)) {
|
||||
referencedExtensions.push(extension.name);
|
||||
}
|
||||
});
|
||||
const requireRebuild =
|
||||
referencedColumns.length || referencedExtensions.length;
|
||||
|
||||
const handleDeleteColumn = () => {
|
||||
deleteColumn(column.key);
|
||||
if (requireRebuild) {
|
||||
snackLogContext.requestSnackLog();
|
||||
rowyRun({
|
||||
route: runRoutes.buildFunction,
|
||||
body: {
|
||||
tablePath: tableSettings.collection,
|
||||
// pathname must match old URL format
|
||||
pathname: getTableBuildFunctionPathname(
|
||||
tableSettings.id,
|
||||
tableSettings.tableType
|
||||
),
|
||||
tableConfigPath: getTableSchemaPath(tableSettings),
|
||||
},
|
||||
});
|
||||
logEvent(analytics, "deployed_extensions");
|
||||
}
|
||||
logEvent(analytics, "delete_column", { type: column.type });
|
||||
handleClose();
|
||||
};
|
||||
@@ -360,8 +403,8 @@ export default function ColumnMenu() {
|
||||
icon: <ColumnRemoveIcon />,
|
||||
onClick: altPress
|
||||
? handleDeleteColumn
|
||||
: () =>
|
||||
confirm({
|
||||
: () => {
|
||||
return confirm({
|
||||
title: "Delete column?",
|
||||
body: (
|
||||
<>
|
||||
@@ -373,12 +416,39 @@ export default function ColumnMenu() {
|
||||
<Typography sx={{ mt: 1 }}>
|
||||
Key: <code style={{ userSelect: "all" }}>{column.key}</code>
|
||||
</Typography>
|
||||
{requireRebuild ? (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
{referencedColumns.length ? (
|
||||
<Typography sx={{ mt: 1 }}>
|
||||
This column will be removed as a dependency of the
|
||||
following columns:{" "}
|
||||
<Typography fontWeight="bold" component="span">
|
||||
{referencedColumns.join(", ")}
|
||||
</Typography>
|
||||
</Typography>
|
||||
) : null}
|
||||
{referencedExtensions.length ? (
|
||||
<Typography sx={{ mt: 1 }}>
|
||||
This column will be removed as a dependency from the
|
||||
following Extensions:{" "}
|
||||
<Typography fontWeight="bold" component="span">
|
||||
{referencedExtensions.join(", ")}
|
||||
</Typography>
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography sx={{ mt: 1, fontWeight: "bold" }}>
|
||||
You need to re-deploy this table’s cloud function.
|
||||
</Typography>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
confirm: "Delete",
|
||||
confirm: requireRebuild ? "Delete & re-deploy" : "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: handleDeleteColumn,
|
||||
}),
|
||||
});
|
||||
},
|
||||
color: "error" as "error",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function NewColumnModal({
|
||||
|
||||
const [columnLabel, setColumnLabel] = useState("");
|
||||
const [fieldKey, setFieldKey] = useState("");
|
||||
const [type, setType] = useState(FieldType.shortText);
|
||||
const [type, setType] = useState("" as any);
|
||||
const requireConfiguration = getFieldProp("requireConfiguration", type);
|
||||
|
||||
const isAuditField = AUDIT_FIELD_TYPES.includes(type);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import {
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
TextField,
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import MemoizedText from "@src/components/Modal/MemoizedText";
|
||||
|
||||
import { FadeTransitionMui } from "@src/components/Modal/FadeTransition";
|
||||
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
|
||||
|
||||
export interface IConfirmDialogProps {
|
||||
@@ -49,10 +49,15 @@ export default function ConfirmDialog({
|
||||
|
||||
const handleClose = () => {
|
||||
setState({ open: false });
|
||||
setDryText("");
|
||||
setDisableConfirm(false);
|
||||
};
|
||||
|
||||
const [dryText, setDryText] = useState("");
|
||||
const [disableConfirm, setDisableConfirm] = useState(
|
||||
Boolean(confirmationCommand)
|
||||
);
|
||||
useEffect(() => {
|
||||
setDisableConfirm(Boolean(confirmationCommand));
|
||||
}, [confirmationCommand]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -62,65 +67,74 @@ export default function ConfirmDialog({
|
||||
else handleClose();
|
||||
}}
|
||||
maxWidth={maxWidth}
|
||||
TransitionComponent={FadeTransitionMui}
|
||||
sx={{ cursor: "default", zIndex: (theme) => theme.zIndex.modal + 50 }}
|
||||
>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<>
|
||||
<MemoizedText>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</MemoizedText>
|
||||
|
||||
<DialogContent>
|
||||
{typeof body === "string" ? (
|
||||
<DialogContentText>{body}</DialogContentText>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
{confirmationCommand && (
|
||||
<TextField
|
||||
value={dryText}
|
||||
onChange={(e) => setDryText(e.target.value)}
|
||||
autoFocus
|
||||
label={`Type “${confirmationCommand}” below to continue:`}
|
||||
placeholder={confirmationCommand}
|
||||
fullWidth
|
||||
id="dryText"
|
||||
sx={{ mt: 3 }}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<MemoizedText>
|
||||
<DialogContent>
|
||||
{typeof body === "string" ? (
|
||||
<DialogContentText>{body}</DialogContentText>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
|
||||
<DialogActions
|
||||
sx={[
|
||||
buttonLayout === "vertical" && {
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
"& > :not(:first-of-type)": { ml: 0, mt: 1 },
|
||||
},
|
||||
]}
|
||||
>
|
||||
{!hideCancel && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (handleCancel) handleCancel();
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
{cancel}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (handleConfirm) handleConfirm();
|
||||
handleClose();
|
||||
}}
|
||||
color={confirmColor || "primary"}
|
||||
variant="contained"
|
||||
autoFocus
|
||||
disabled={
|
||||
confirmationCommand ? dryText !== confirmationCommand : false
|
||||
}
|
||||
{confirmationCommand && (
|
||||
<TextField
|
||||
onChange={(e) =>
|
||||
setDisableConfirm(e.target.value !== confirmationCommand)
|
||||
}
|
||||
label={`Type “${confirmationCommand}” below to continue:`}
|
||||
placeholder={confirmationCommand}
|
||||
fullWidth
|
||||
id="dryText"
|
||||
sx={{ mt: 3 }}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</MemoizedText>
|
||||
|
||||
<DialogActions
|
||||
sx={[
|
||||
buttonLayout === "vertical" && {
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
"& > :not(:first-of-type)": { ml: 0, mt: 1 },
|
||||
},
|
||||
]}
|
||||
>
|
||||
{confirm}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
<MemoizedText>
|
||||
{!hideCancel && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (handleCancel) handleCancel();
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
{cancel}
|
||||
</Button>
|
||||
)}
|
||||
</MemoizedText>
|
||||
|
||||
<MemoizedText key={disableConfirm.toString()}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (handleConfirm) handleConfirm();
|
||||
handleClose();
|
||||
}}
|
||||
color={confirmColor || "primary"}
|
||||
variant="contained"
|
||||
autoFocus
|
||||
disabled={disableConfirm}
|
||||
>
|
||||
{confirm}
|
||||
</Button>
|
||||
</MemoizedText>
|
||||
</DialogActions>
|
||||
</>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { FallbackProps } from "react-error-boundary";
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import useOffline from "@src/hooks/useOffline";
|
||||
|
||||
import { Typography, Button } from "@mui/material";
|
||||
import ReloadIcon from "@mui/icons-material/Refresh";
|
||||
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||
import { Tables as TablesIcon } from "@src/assets/icons";
|
||||
import OfflineIcon from "@mui/icons-material/CloudOff";
|
||||
|
||||
import EmptyState, { IEmptyStateProps } from "@src/components/EmptyState";
|
||||
import AccessDenied from "@src/components/AccessDenied";
|
||||
|
||||
import { ROUTES } from "@src/constants/routes";
|
||||
import meta from "@root/package.json";
|
||||
|
||||
export const ERROR_TABLE_NOT_FOUND = "Table not found";
|
||||
import { EXTERNAL_LINKS } from "@src/constants/externalLinks";
|
||||
|
||||
export interface IErrorFallbackProps extends FallbackProps, IEmptyStateProps {}
|
||||
|
||||
@@ -22,6 +20,8 @@ export function ErrorFallbackContents({
|
||||
resetErrorBoundary,
|
||||
...props
|
||||
}: IErrorFallbackProps) {
|
||||
const isOffline = useOffline();
|
||||
|
||||
if ((error as any).code === "permission-denied")
|
||||
return (
|
||||
<AccessDenied error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
@@ -33,14 +33,28 @@ export function ErrorFallbackContents({
|
||||
<>
|
||||
<Typography variant="inherit" style={{ whiteSpace: "pre-line" }}>
|
||||
{(error as any).code && <b>{(error as any).code}: </b>}
|
||||
{(error as any).status && <b>{(error as any).status}: </b>}
|
||||
{error.message}
|
||||
</Typography>
|
||||
<Button
|
||||
size={props.basic ? "small" : "medium"}
|
||||
href={
|
||||
meta.repository.url.replace(".git", "") +
|
||||
"/issues/new?labels=bug&template=bug_report.md&title=Error: " +
|
||||
error.message.replace("\n", " ")
|
||||
EXTERNAL_LINKS.gitHub +
|
||||
"/discussions/new?" +
|
||||
new URLSearchParams({
|
||||
labels: "bug",
|
||||
category: "support-q-a",
|
||||
title: [
|
||||
"Error",
|
||||
(error as any).code,
|
||||
(error as any).status,
|
||||
error.message,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(": ")
|
||||
.replace(/\n/g, " "),
|
||||
body: "👉 **Please describe the steps that you took that led to this bug.**",
|
||||
}).toString()
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -52,50 +66,44 @@ export function ErrorFallbackContents({
|
||||
),
|
||||
};
|
||||
|
||||
if (error.message.startsWith(ERROR_TABLE_NOT_FOUND)) {
|
||||
renderProps = {
|
||||
message: ERROR_TABLE_NOT_FOUND,
|
||||
description: (
|
||||
<>
|
||||
<Typography variant="inherit">
|
||||
Make sure you have the right ID
|
||||
</Typography>
|
||||
<code>{error.message.replace(ERROR_TABLE_NOT_FOUND + ": ", "")}</code>
|
||||
<Button
|
||||
size={props.basic ? "small" : "medium"}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
component={Link}
|
||||
to={ROUTES.tables}
|
||||
startIcon={<TablesIcon />}
|
||||
onClick={() => resetErrorBoundary()}
|
||||
>
|
||||
All tables
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (error.message.startsWith("Loading chunk")) {
|
||||
renderProps = {
|
||||
Icon: ReloadIcon,
|
||||
message: "New update available",
|
||||
description: (
|
||||
<>
|
||||
<Typography variant="inherit">
|
||||
Reload this page to get the latest update
|
||||
</Typography>
|
||||
if (isOffline) {
|
||||
renderProps = { Icon: OfflineIcon, message: "You’re offline" };
|
||||
} else {
|
||||
renderProps = {
|
||||
Icon: ReloadIcon,
|
||||
message: "Update available",
|
||||
description: (
|
||||
<Button
|
||||
size={props.basic ? "small" : "medium"}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon={<ReloadIcon />}
|
||||
onClick={() => window.location.reload()}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (error.message.includes("Failed to fetch")) {
|
||||
renderProps = {
|
||||
Icon: OfflineIcon,
|
||||
message: "You’re offline",
|
||||
description: isOffline ? null : (
|
||||
<Button
|
||||
size={props.basic ? "small" : "medium"}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon={<ReloadIcon />}
|
||||
onClick={() => window.location.reload()}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { forwardRef, cloneElement } from "react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { Transition } from "react-transition-group";
|
||||
import { TransitionProps } from "react-transition-group/Transition";
|
||||
import { TransitionProps as MuiTransitionProps } from "@mui/material/transitions";
|
||||
|
||||
export const FadeTransition: React.ForwardRefExoticComponent<
|
||||
Pick<TransitionProps, React.ReactText> & React.RefAttributes<any>
|
||||
> = forwardRef(
|
||||
({ children, ...props }: TransitionProps, ref: React.Ref<any>) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (!children) return null;
|
||||
|
||||
const defaultStyle = {
|
||||
opacity: 0,
|
||||
transform: "scale(0.8)",
|
||||
|
||||
transition: theme.transitions.create(["transform", "opacity"], {
|
||||
duration: "300ms",
|
||||
easing: theme.transitions.easing.strong,
|
||||
}),
|
||||
};
|
||||
|
||||
const transitionStyles = {
|
||||
entering: {
|
||||
willChange: "transform, opacity",
|
||||
},
|
||||
|
||||
entered: {
|
||||
opacity: 1,
|
||||
transform: "none",
|
||||
},
|
||||
|
||||
exiting: {
|
||||
opacity: 0,
|
||||
transform: "scale(0.8)",
|
||||
|
||||
transitionDuration: theme.transitions.duration.leavingScreen,
|
||||
},
|
||||
|
||||
exited: {
|
||||
opacity: 0,
|
||||
transform: "none",
|
||||
transition: "none",
|
||||
},
|
||||
|
||||
unmounted: {},
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
appear
|
||||
timeout={{ enter: 0, exit: theme.transitions.duration.leavingScreen }}
|
||||
{...props}
|
||||
>
|
||||
{(state) =>
|
||||
cloneElement(children as any, {
|
||||
style: { ...defaultStyle, ...transitionStyles[state] },
|
||||
tabIndex: -1,
|
||||
ref,
|
||||
})
|
||||
}
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default FadeTransition;
|
||||
|
||||
export const FadeTransitionMui = forwardRef(function Transition(
|
||||
props: MuiTransitionProps & { children?: React.ReactElement<any, any> },
|
||||
ref: React.Ref<unknown>
|
||||
) {
|
||||
return <FadeTransition ref={ref} {...props} />;
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { memo } from "react";
|
||||
import { memo, PropsWithChildren } from "react";
|
||||
|
||||
/**
|
||||
* Used for global Modals that can have customizable text
|
||||
* so that the default text doesn’t appear as the modal closes.
|
||||
*/
|
||||
const MemoizedText = memo(
|
||||
function MemoizedTextComponent({ text }: { text: React.ReactNode }) {
|
||||
return <>{text}</>;
|
||||
function MemoizedTextComponent({ children }: PropsWithChildren<{}>) {
|
||||
return <>{children}</>;
|
||||
},
|
||||
() => true
|
||||
);
|
||||
|
||||
@@ -11,12 +11,10 @@ import {
|
||||
DialogActions,
|
||||
Button,
|
||||
ButtonProps,
|
||||
Slide,
|
||||
} from "@mui/material";
|
||||
import LoadingButton, { LoadingButtonProps } from "@mui/lab/LoadingButton";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
import { FadeTransitionMui } from "./FadeTransition";
|
||||
import ScrollableDialogContent, {
|
||||
IScrollableDialogContentProps,
|
||||
} from "./ScrollableDialogContent";
|
||||
@@ -86,8 +84,6 @@ export default function Modal({
|
||||
onClose={handleClose}
|
||||
fullWidth
|
||||
fullScreen={fullScreen}
|
||||
TransitionComponent={fullScreen ? Slide : FadeTransitionMui}
|
||||
TransitionProps={fullScreen ? ({ direction: "up" } as any) : undefined}
|
||||
aria-labelledby="modal-title"
|
||||
{...props}
|
||||
sx={
|
||||
|
||||
91
src/components/Modal/ModalTransition.tsx
Normal file
91
src/components/Modal/ModalTransition.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { forwardRef, cloneElement } from "react";
|
||||
import { useTheme, Slide } from "@mui/material";
|
||||
import { Transition } from "react-transition-group";
|
||||
import { TransitionProps } from "react-transition-group/Transition";
|
||||
import { TransitionProps as MuiTransitionProps } from "@mui/material/transitions";
|
||||
|
||||
export const ModalTransition: React.ForwardRefExoticComponent<
|
||||
Pick<TransitionProps, React.ReactText> & React.RefAttributes<any>
|
||||
> = forwardRef(function ModalTransition(
|
||||
{ children, ...props }: TransitionProps,
|
||||
ref: React.Ref<any>
|
||||
) {
|
||||
const theme = useTheme();
|
||||
|
||||
if (!children) return null;
|
||||
|
||||
const isFullScreenDialog = (
|
||||
Array.isArray(children) ? children[0] : children
|
||||
).props?.children?.props?.className?.includes("MuiDialog-paperFullScreen");
|
||||
|
||||
if (isFullScreenDialog)
|
||||
return (
|
||||
<Slide direction="up" appear {...props}>
|
||||
{children as any}
|
||||
</Slide>
|
||||
);
|
||||
|
||||
const defaultStyle = {
|
||||
opacity: 0,
|
||||
transform: "scale(0.8)",
|
||||
|
||||
transition: theme.transitions.create(["transform", "opacity"], {
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
easing: theme.transitions.easing.strong,
|
||||
}),
|
||||
};
|
||||
|
||||
const transitionStyles = {
|
||||
entering: {
|
||||
willChange: "transform, opacity",
|
||||
},
|
||||
|
||||
entered: {
|
||||
opacity: 1,
|
||||
transform: "none",
|
||||
},
|
||||
|
||||
exiting: {
|
||||
opacity: 0,
|
||||
transform: "scale(0.8)",
|
||||
|
||||
transitionDuration: theme.transitions.duration.leavingScreen,
|
||||
},
|
||||
|
||||
exited: {
|
||||
opacity: 0,
|
||||
transform: "none",
|
||||
transition: "none",
|
||||
},
|
||||
|
||||
unmounted: {},
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
appear
|
||||
timeout={{
|
||||
enter: theme.transitions.duration.enteringScreen,
|
||||
exit: theme.transitions.duration.leavingScreen,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{(state) =>
|
||||
cloneElement(children as any, {
|
||||
style: { ...defaultStyle, ...transitionStyles[state] },
|
||||
tabIndex: -1,
|
||||
ref,
|
||||
})
|
||||
}
|
||||
</Transition>
|
||||
);
|
||||
});
|
||||
|
||||
export default ModalTransition;
|
||||
|
||||
export const ModalTransitionMui = forwardRef(function Transition(
|
||||
props: MuiTransitionProps & { children?: React.ReactElement<any, any> },
|
||||
ref: React.Ref<unknown>
|
||||
) {
|
||||
return <ModalTransition ref={ref} {...props} />;
|
||||
});
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
Button,
|
||||
DialogContentText,
|
||||
Link as MuiLink,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import CheckIcon from "@mui/icons-material/CheckCircle";
|
||||
|
||||
import Modal from "@src/components/Modal";
|
||||
import Logo from "@src/assets/LogoRowyRun";
|
||||
import MemoizedText from "@src/components/Modal/MemoizedText";
|
||||
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||
|
||||
import {
|
||||
@@ -43,45 +45,73 @@ export default function RowyRunModal() {
|
||||
open={rowyRunModal.open}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Logo
|
||||
size={2}
|
||||
style={{
|
||||
margin: "16px auto",
|
||||
display: "block",
|
||||
position: "relative",
|
||||
right: 44 / -2,
|
||||
}}
|
||||
/>
|
||||
<MemoizedText>
|
||||
{rowyRunModal.feature
|
||||
? `${
|
||||
showUpdateModal ? "Update" : "Set up"
|
||||
} Cloud Functions to use ${rowyRunModal.feature}`
|
||||
: `Your Cloud isn’t set up`}
|
||||
</MemoizedText>
|
||||
}
|
||||
maxWidth="xs"
|
||||
body={
|
||||
<>
|
||||
<Typography variant="h5" paragraph align="center">
|
||||
{showUpdateModal ? "Update" : "Set up"} Rowy Run to use{" "}
|
||||
{rowyRunModal.feature || "this feature"}
|
||||
</Typography>
|
||||
|
||||
{showUpdateModal && (
|
||||
<DialogContentText variant="body1" paragraph textAlign="center">
|
||||
<DialogContentText variant="button" paragraph>
|
||||
{rowyRunModal.feature || "This feature"} requires Rowy Run v
|
||||
{rowyRunModal.version} or later.
|
||||
</DialogContentText>
|
||||
)}
|
||||
|
||||
<DialogContentText variant="body1" paragraph textAlign="center">
|
||||
Rowy Run is a Cloud Run instance that provides backend
|
||||
functionality, such as table action scripts, user management, and
|
||||
easy Cloud Function deployment.{" "}
|
||||
<MuiLink
|
||||
href={WIKI_LINKS.rowyRun}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more
|
||||
<InlineOpenInNewIcon />
|
||||
</MuiLink>
|
||||
<DialogContentText paragraph>
|
||||
Cloud Functions are free to use in our Base plan, you just need to
|
||||
set a few things up first. Enable Cloud Functions for:
|
||||
</DialogContentText>
|
||||
|
||||
<Box
|
||||
component="ol"
|
||||
sx={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
alignSelf: "stretch",
|
||||
"& li": {
|
||||
listStyleType: "none",
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
marginBottom: 2,
|
||||
|
||||
"& svg": {
|
||||
display: "flex",
|
||||
fontSize: "1.25rem",
|
||||
color: "action.active",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<li>
|
||||
<CheckIcon />
|
||||
Derivative fields, Extensions, Webhooks
|
||||
</li>
|
||||
<li>
|
||||
<CheckIcon />
|
||||
Table and Action scripts
|
||||
</li>
|
||||
<li>
|
||||
<CheckIcon />
|
||||
Easy Cloud Function deployment
|
||||
</li>
|
||||
</Box>
|
||||
|
||||
<MuiLink
|
||||
href={WIKI_LINKS.rowyRun}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ display: "flex", mb: 3 }}
|
||||
>
|
||||
Learn more
|
||||
<InlineOpenInNewIcon />
|
||||
</MuiLink>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to={ROUTES.projectSettings + "#rowyRun"}
|
||||
@@ -92,7 +122,7 @@ export default function RowyRunModal() {
|
||||
style={{ display: "flex" }}
|
||||
disabled={!userRoles.includes("ADMIN")}
|
||||
>
|
||||
Set up Rowy Run
|
||||
Set up Cloud Functions
|
||||
</Button>
|
||||
|
||||
{!userRoles.includes("ADMIN") && (
|
||||
@@ -102,7 +132,7 @@ export default function RowyRunModal() {
|
||||
color="error"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Contact the project owner to set up Rowy Run
|
||||
Only admins can set up Cloud Functions
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,9 @@ export default function Authentication({
|
||||
publicSettings,
|
||||
updatePublicSettings,
|
||||
}: IProjectSettingsChildProps) {
|
||||
const [signInOptions, setSignInOptions] = useState(
|
||||
const [signInOptions, setSignInOptions] = useState<
|
||||
NonNullable<typeof publicSettings["signInOptions"]>
|
||||
>(
|
||||
Array.isArray(publicSettings?.signInOptions)
|
||||
? publicSettings.signInOptions
|
||||
: ["google"]
|
||||
@@ -23,10 +25,12 @@ export default function Authentication({
|
||||
<MultiSelect
|
||||
label="Sign-in options"
|
||||
value={signInOptions}
|
||||
options={Object.keys(authOptions).map((option) => ({
|
||||
value: option,
|
||||
label: startCase(option).replace("Github", "GitHub"),
|
||||
}))}
|
||||
options={
|
||||
Object.keys(authOptions).map((option) => ({
|
||||
value: option,
|
||||
label: startCase(option).replace("Github", "GitHub"),
|
||||
})) as any
|
||||
}
|
||||
onChange={setSignInOptions}
|
||||
onClose={() => updatePublicSettings({ signInOptions })}
|
||||
multiple
|
||||
|
||||
@@ -13,8 +13,8 @@ export default function Customization({
|
||||
updatePublicSettings,
|
||||
}: IProjectSettingsChildProps) {
|
||||
const [customizedThemeColor, setCustomizedThemeColor] = useState(
|
||||
publicSettings.theme?.light?.palette?.primary?.main ||
|
||||
publicSettings.theme?.dark?.palette?.primary?.main
|
||||
(publicSettings.theme?.light?.palette?.primary as any)?.main ||
|
||||
(publicSettings.theme?.dark?.palette?.primary as any)?.main
|
||||
);
|
||||
|
||||
const handleSave = ({ light, dark }: { light: string; dark: string }) => {
|
||||
@@ -50,8 +50,12 @@ export default function Customization({
|
||||
<Collapse in={customizedThemeColor} style={{ marginTop: 0 }}>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ThemeColorPicker
|
||||
currentLight={publicSettings.theme?.light?.palette?.primary?.main}
|
||||
currentDark={publicSettings.theme?.dark?.palette?.primary?.main}
|
||||
currentLight={
|
||||
(publicSettings.theme?.light?.palette?.primary as any)?.main
|
||||
}
|
||||
currentDark={
|
||||
(publicSettings.theme?.dark?.palette?.primary as any)?.main
|
||||
}
|
||||
handleSave={handleSave}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -23,12 +23,12 @@ import {
|
||||
projectSettingsAtom,
|
||||
rowyRunAtom,
|
||||
rowyRunModalAtom,
|
||||
UserSettings,
|
||||
updateUserAtom,
|
||||
confirmDialogAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import { runRoutes } from "@src/constants/runRoutes";
|
||||
import { USERS } from "@src/config/dbPaths";
|
||||
import type { UserSettings } from "@src/types/settings";
|
||||
|
||||
export default function UserItem({
|
||||
_rowy_ref,
|
||||
|
||||
@@ -9,19 +9,19 @@ export default function Account({ settings }: IUserSettingsChildProps) {
|
||||
return (
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item>
|
||||
<Avatar src={settings.user.photoURL} />
|
||||
<Avatar src={settings.user?.photoURL} />
|
||||
</Grid>
|
||||
|
||||
<Grid item xs>
|
||||
<Typography variant="body1" style={{ userSelect: "all" }}>
|
||||
{settings.user.displayName}
|
||||
{settings.user?.displayName}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
style={{ userSelect: "all" }}
|
||||
>
|
||||
{settings.user.email}
|
||||
{settings.user?.email}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ export default function Personalization({
|
||||
}: IUserSettingsChildProps) {
|
||||
const [customizedThemeColor, setCustomizedThemeColor] = useState(
|
||||
Boolean(
|
||||
settings.theme?.light?.palette?.primary?.main ||
|
||||
settings.theme?.dark?.palette?.primary?.main
|
||||
(settings.theme?.light?.palette?.primary as any)?.main ||
|
||||
(settings.theme?.dark?.palette?.primary as any)?.main
|
||||
)
|
||||
);
|
||||
|
||||
@@ -52,8 +52,10 @@ export default function Personalization({
|
||||
<Collapse in={customizedThemeColor} style={{ marginTop: 0 }}>
|
||||
<Suspense fallback={<Loading style={{ height: "auto" }} />}>
|
||||
<ThemeColorPicker
|
||||
currentLight={settings.theme?.light?.palette?.primary?.main}
|
||||
currentDark={settings.theme?.dark?.palette?.primary?.main}
|
||||
currentLight={
|
||||
(settings.theme?.light?.palette?.primary as any)?.main
|
||||
}
|
||||
currentDark={(settings.theme?.dark?.palette?.primary as any)?.main}
|
||||
handleSave={handleSave}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -62,7 +62,9 @@ export default function Theme({
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
defaultChecked={Boolean(settings.theme?.dark?.palette?.darker)}
|
||||
defaultChecked={Boolean(
|
||||
(settings.theme?.dark?.palette as any)?.darker
|
||||
)}
|
||||
onChange={(e) => {
|
||||
updateSettings({
|
||||
theme: merge(settings.theme, {
|
||||
|
||||
@@ -122,7 +122,9 @@ export default function SideDrawer({
|
||||
)}
|
||||
variant="permanent"
|
||||
anchor="right"
|
||||
PaperProps={{ elevation: 4, component: "aside" } as any}
|
||||
PaperProps={
|
||||
{ elevation: 4, component: "aside", "aria-label": "Side drawer" } as any
|
||||
}
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<div className="sidedrawer-contents">
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function MenuContents({ onClose }: IMenuContentsProps) {
|
||||
disabled:
|
||||
selectedColumn.editable === false ||
|
||||
!row ||
|
||||
cellValue ||
|
||||
cellValue === undefined ||
|
||||
getFieldProp("group", selectedColumn.type) === "Auditing",
|
||||
onClick: altPress
|
||||
? handleClearValue
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { Offline, Online } from "react-detect-offline";
|
||||
|
||||
import { Grid, Stack, Typography, Button, Divider } from "@mui/material";
|
||||
import { Import as ImportIcon } from "@src/assets/icons";
|
||||
import { AddColumn as AddColumnIcon } from "@src/assets/icons";
|
||||
import {
|
||||
Import as ImportIcon,
|
||||
AddColumn as AddColumnIcon,
|
||||
} from "@src/assets/icons";
|
||||
import OfflineIcon from "@mui/icons-material/CloudOff";
|
||||
|
||||
import EmptyState from "@src/components/EmptyState";
|
||||
import ImportData from "@src/components/TableToolbar/ImportData/ImportData";
|
||||
|
||||
import {
|
||||
@@ -22,10 +27,14 @@ export default function EmptyTable() {
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||
// const { tableState, importWizardRef, columnMenuRef } = useProjectContext();
|
||||
|
||||
// check if theres any rows, and if rows include fields other than rowy_ref
|
||||
const hasData =
|
||||
tableRows.length > 0
|
||||
? tableRows.some((row) => Object.keys(row).length > 1)
|
||||
: false;
|
||||
let contents = <></>;
|
||||
|
||||
if (tableRows.length > 0) {
|
||||
if (hasData) {
|
||||
contents = (
|
||||
<>
|
||||
<div>
|
||||
@@ -125,21 +134,35 @@ export default function EmptyTable() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
spacing={3}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
height: `calc(100vh - ${TOP_BAR_HEIGHT}px)`,
|
||||
width: "100%",
|
||||
p: 2,
|
||||
maxWidth: 480,
|
||||
margin: "0 auto",
|
||||
textAlign: "center",
|
||||
}}
|
||||
id="empty-table"
|
||||
>
|
||||
{contents}
|
||||
</Stack>
|
||||
<>
|
||||
<Offline>
|
||||
<EmptyState
|
||||
role="alert"
|
||||
Icon={OfflineIcon}
|
||||
message="You’re offline"
|
||||
description="Go online to view this table’s data"
|
||||
style={{ height: `calc(100vh - ${TOP_BAR_HEIGHT}px)` }}
|
||||
/>
|
||||
</Offline>
|
||||
|
||||
<Online>
|
||||
<Stack
|
||||
spacing={3}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
height: `calc(100vh - ${TOP_BAR_HEIGHT}px)`,
|
||||
width: "100%",
|
||||
p: 2,
|
||||
maxWidth: 480,
|
||||
margin: "0 auto",
|
||||
textAlign: "center",
|
||||
}}
|
||||
id="empty-table"
|
||||
>
|
||||
{contents}
|
||||
</Stack>
|
||||
</Online>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
244
src/components/TableInformationDrawer/Details.tsx
Normal file
244
src/components/TableInformationDrawer/Details.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { find, isEqual } from "lodash-es";
|
||||
import MDEditor from "@uiw/react-md-editor";
|
||||
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
|
||||
import EditIcon from "@mui/icons-material/EditOutlined";
|
||||
import EditOffIcon from "@mui/icons-material/EditOffOutlined";
|
||||
|
||||
import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
projectScope,
|
||||
tablesAtom,
|
||||
updateTableAtom,
|
||||
userRolesAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||
import SaveState from "@src/components/SideDrawer/SaveState";
|
||||
|
||||
export default function Details() {
|
||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
const [tables] = useAtom(tablesAtom, projectScope);
|
||||
const [updateTable] = useAtom(updateTableAtom, projectScope);
|
||||
const theme = useTheme();
|
||||
|
||||
const settings = useMemo(
|
||||
() => find(tables, ["id", tableSettings.id]),
|
||||
[tables, tableSettings.id]
|
||||
);
|
||||
|
||||
const { description, details, _createdBy } = settings ?? {};
|
||||
|
||||
const [editDescription, setEditDescription] = useState(false);
|
||||
const [localDescription, setLocalDescription] = useState(description ?? "");
|
||||
const [localDetails, setLocalDetails] = useState(details ?? "");
|
||||
const [editDetails, setEditDetails] = useState(false);
|
||||
const [mdFullScreen, setMdFullScreen] = useState(false);
|
||||
|
||||
const [saveState, setSaveState] = useState<
|
||||
"" | "unsaved" | "saving" | "saved"
|
||||
>("");
|
||||
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveState("saving");
|
||||
await updateTable!({
|
||||
...settings,
|
||||
description: localDescription,
|
||||
details: localDetails,
|
||||
});
|
||||
setSaveState("saved");
|
||||
};
|
||||
|
||||
const isAdmin = userRoles.includes("ADMIN");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 3,
|
||||
paddingRight: 4,
|
||||
position: "fixed",
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<SaveState state={saveState} />
|
||||
</Box>
|
||||
<Stack
|
||||
gap={3}
|
||||
sx={{
|
||||
paddingTop: 3,
|
||||
paddingRight: 3,
|
||||
paddingBottom: 5,
|
||||
}}
|
||||
>
|
||||
{/* Description */}
|
||||
<Stack gap={1}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Typography variant="subtitle1" component="h3">
|
||||
Description
|
||||
</Typography>
|
||||
{isAdmin && (
|
||||
<IconButton
|
||||
aria-label="Edit description"
|
||||
onClick={() => {
|
||||
setEditDescription(!editDescription);
|
||||
}}
|
||||
sx={{ top: 4 }}
|
||||
>
|
||||
{editDescription ? <EditOffIcon /> : <EditIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
{editDescription ? (
|
||||
<TextField
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
autoFocus={true}
|
||||
value={localDescription}
|
||||
onChange={(e) => {
|
||||
setLocalDescription(e.target.value);
|
||||
saveState !== "unsaved" && setSaveState("unsaved");
|
||||
}}
|
||||
onBlur={() =>
|
||||
isEqual(description, localDescription)
|
||||
? setSaveState("")
|
||||
: handleSave()
|
||||
}
|
||||
rows={2}
|
||||
minRows={2}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{localDescription ? localDescription : "No description"}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
{/* Details */}
|
||||
<Stack gap={1}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Typography variant="subtitle1" component="h3">
|
||||
Details
|
||||
</Typography>
|
||||
{isAdmin && (
|
||||
<IconButton
|
||||
aria-label="Edit details"
|
||||
onClick={() => {
|
||||
setEditDetails(!editDetails);
|
||||
}}
|
||||
sx={{ top: 4 }}
|
||||
>
|
||||
{editDetails ? <EditOffIcon /> : <EditIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
<Box
|
||||
data-color-mode={theme.palette.mode}
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
...theme.typography.body2,
|
||||
"& .w-md-editor": {
|
||||
backgroundColor: `${theme.palette.action.input} !important`,
|
||||
},
|
||||
"& .w-md-editor-fullscreen": {
|
||||
backgroundColor: `${theme.palette.background.paper} !important`,
|
||||
},
|
||||
"& .w-md-editor-toolbar": {
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
},
|
||||
"& .w-md-editor-toolbar > ul": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
"& .w-md-editor-toolbar > ul:first-of-type": {
|
||||
overflowX: "auto",
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
"& :is(h1, h2, h3, h4, h5, h6)": {
|
||||
marginY: `${theme.spacing(1.5)} !important`,
|
||||
borderBottom: "none !important",
|
||||
},
|
||||
"& details summary": {
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{editDetails ? (
|
||||
<MDEditor
|
||||
style={{ margin: 1 }}
|
||||
value={localDetails}
|
||||
preview={mdFullScreen ? "live" : "edit"}
|
||||
commandsFilter={(command) => {
|
||||
if (command.name === "fullscreen") {
|
||||
command.execute = () => setMdFullScreen(!mdFullScreen);
|
||||
}
|
||||
return command;
|
||||
}}
|
||||
textareaProps={{
|
||||
autoFocus: true,
|
||||
onChange: (e) => {
|
||||
setLocalDetails(e.target.value ?? "");
|
||||
saveState !== "unsaved" && setSaveState("unsaved");
|
||||
},
|
||||
onBlur: () =>
|
||||
isEqual(details, localDetails)
|
||||
? setSaveState("")
|
||||
: handleSave(),
|
||||
}}
|
||||
/>
|
||||
) : !localDetails ? (
|
||||
<Typography variant="body2">No details</Typography>
|
||||
) : (
|
||||
<MDEditor.Markdown source={localDetails} />
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
{/* Table Audits */}
|
||||
{_createdBy && (
|
||||
<Stack>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
component="div"
|
||||
style={{ whiteSpace: "normal" }}
|
||||
>
|
||||
Created by{" "}
|
||||
<Typography variant="caption" color="text.primary">
|
||||
{_createdBy.displayName}
|
||||
</Typography>{" "}
|
||||
on{" "}
|
||||
<Typography variant="caption" color="text.primary">
|
||||
{format(_createdBy.timestamp.toDate(), DATE_TIME_FORMAT)}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
src/components/TableInformationDrawer/TableInformationDrawer.tsx
Normal file
134
src/components/TableInformationDrawer/TableInformationDrawer.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import clsx from "clsx";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
drawerClasses,
|
||||
IconButton,
|
||||
Stack,
|
||||
styled,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
import { sideDrawerAtom, tableScope } from "@src/atoms/tableScope";
|
||||
|
||||
import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar";
|
||||
import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar/TableToolbar";
|
||||
import ErrorFallback from "@src/components/ErrorFallback";
|
||||
import Details from "./Details";
|
||||
|
||||
export const DRAWER_WIDTH = 450;
|
||||
|
||||
export const StyledDrawer = styled(Drawer)(({ theme }) => ({
|
||||
[`.${drawerClasses.root}`]: {
|
||||
width: DRAWER_WIDTH,
|
||||
flexShrink: 0,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
|
||||
[`.${drawerClasses.paper}`]: {
|
||||
border: "none",
|
||||
boxShadow: theme.shadows[4].replace(/, 0 (\d+px)/g, ", -$1 0"),
|
||||
borderTopLeftRadius: `${(theme.shape.borderRadius as number) * 3}px`,
|
||||
borderBottomLeftRadius: `${(theme.shape.borderRadius as number) * 3}px`,
|
||||
|
||||
width: DRAWER_WIDTH,
|
||||
maxWidth: `calc(100% - 28px - ${theme.spacing(1)})`,
|
||||
|
||||
boxSizing: "content-box",
|
||||
|
||||
top: TOP_BAR_HEIGHT + TABLE_TOOLBAR_HEIGHT,
|
||||
height: `calc(100% - ${TOP_BAR_HEIGHT + TABLE_TOOLBAR_HEIGHT}px)`,
|
||||
".MuiDialog-paperFullScreen &": {
|
||||
top:
|
||||
TOP_BAR_HEIGHT +
|
||||
TABLE_TOOLBAR_HEIGHT +
|
||||
Number(theme.spacing(2).replace("px", "")),
|
||||
height: `calc(100% - ${
|
||||
TOP_BAR_HEIGHT + TABLE_TOOLBAR_HEIGHT
|
||||
}px - ${theme.spacing(2)})`,
|
||||
},
|
||||
|
||||
transition: theme.transitions.create("transform", {
|
||||
easing: theme.transitions.easing.easeInOut,
|
||||
duration: theme.transitions.duration.standard,
|
||||
}),
|
||||
|
||||
zIndex: theme.zIndex.drawer - 1,
|
||||
},
|
||||
[`:not(.sidedrawer-open) .${drawerClasses.paper}`]: {
|
||||
transform: `translateX(calc(100% - env(safe-area-inset-right)))`,
|
||||
},
|
||||
|
||||
".sidedrawer-contents": {
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
marginLeft: theme.spacing(5),
|
||||
marginRight: `max(env(safe-area-inset-right), ${theme.spacing(1)})`,
|
||||
marginTop: theme.spacing(2),
|
||||
paddingBottom: theme.spacing(5),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function SideDrawer() {
|
||||
const [sideDrawer, setSideDrawer] = useAtom(sideDrawerAtom, tableScope);
|
||||
|
||||
// const DetailsComponent =
|
||||
// userRoles.includes("ADMIN") && tableSettings.templateSettings
|
||||
// ? withTemplate(Details)
|
||||
// : Details;
|
||||
// const DetailsComponent = Details;
|
||||
const open = sideDrawer === "table-information";
|
||||
|
||||
return (
|
||||
<StyledDrawer
|
||||
className={clsx(open && "sidedrawer-open")}
|
||||
open={open}
|
||||
variant="permanent"
|
||||
anchor="right"
|
||||
PaperProps={{ elevation: 4, component: "aside" } as any}
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
{open && (
|
||||
<div className="sidedrawer-contents">
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
pr={3}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
py={1}
|
||||
>
|
||||
<Typography variant="h5" component="h2">
|
||||
Information
|
||||
</Typography>
|
||||
</Stack>
|
||||
<IconButton
|
||||
onClick={() => setSideDrawer(RESET)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<Details />
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</StyledDrawer>
|
||||
);
|
||||
}
|
||||
2
src/components/TableInformationDrawer/index.ts
Normal file
2
src/components/TableInformationDrawer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./TableInformationDrawer";
|
||||
export { default } from "./TableInformationDrawer";
|
||||
@@ -107,7 +107,7 @@ export default function BuildLogsSnack({
|
||||
borderRadius: 1,
|
||||
zIndex: 1,
|
||||
transition: (theme) => theme.transitions.create("height"),
|
||||
height: expanded ? "calc(100% - 300px)" : 300,
|
||||
height: expanded ? "calc(100% - 300px)" : 50,
|
||||
}}
|
||||
>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
@@ -178,7 +178,7 @@ export default function BuildLogsSnack({
|
||||
height={"calc(100% - 25px)"}
|
||||
id="live-stream-scroll-box-snack"
|
||||
>
|
||||
{latestActiveLog && (
|
||||
{latestActiveLog && expanded && (
|
||||
<>
|
||||
{logs?.map((log: any, index: number) => (
|
||||
<BuildLogRow logRecord={log} index={index} key={index} />
|
||||
|
||||
@@ -137,7 +137,11 @@ export default function Export({
|
||||
<ColumnSelect
|
||||
value={columns.map((x) => x.key)}
|
||||
onChange={handleChange(setColumns)}
|
||||
filterColumns={(column) => DOWNLOADABLE_COLUMNS.includes(column.type)}
|
||||
filterColumns={(column) =>
|
||||
column.type === FieldType.derivative
|
||||
? DOWNLOADABLE_COLUMNS.includes(column.config?.renderFieldType)
|
||||
: DOWNLOADABLE_COLUMNS.includes(column.type)
|
||||
}
|
||||
label="Columns to export"
|
||||
labelPlural="columns"
|
||||
TextFieldProps={{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { isEqual, isUndefined } from "lodash-es";
|
||||
import { ITableModalProps } from "@src/components/TableModals";
|
||||
|
||||
import Modal from "@src/components/Modal";
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from "@src/atoms/tableScope";
|
||||
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
|
||||
|
||||
import { emptyExtensionObject, IExtension, ExtensionType } from "./utils";
|
||||
import { runRoutes } from "@src/constants/runRoutes";
|
||||
import { analytics, logEvent } from "@src/analytics";
|
||||
import {
|
||||
@@ -31,6 +30,14 @@ import {
|
||||
getTableBuildFunctionPathname,
|
||||
} from "@src/utils/table";
|
||||
|
||||
import {
|
||||
emptyExtensionObject,
|
||||
IExtension,
|
||||
ExtensionType,
|
||||
IRuntimeOptions,
|
||||
} from "./utils";
|
||||
import RuntimeOptions from "./RuntimeOptions";
|
||||
|
||||
export default function ExtensionsModal({ onClose }: ITableModalProps) {
|
||||
const [currentUser] = useAtom(currentUserAtom, projectScope);
|
||||
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
|
||||
@@ -39,12 +46,25 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope);
|
||||
|
||||
const currentExtensionObjects = (tableSchema.extensionObjects ??
|
||||
[]) as IExtension[];
|
||||
const [localExtensionsObjects, setLocalExtensionsObjects] = useState(
|
||||
currentExtensionObjects
|
||||
tableSchema.extensionObjects ?? []
|
||||
);
|
||||
|
||||
const [localRuntimeOptions, setLocalRuntimeOptions] = useState(
|
||||
tableSchema.runtimeOptions ?? {}
|
||||
);
|
||||
|
||||
const errors = {
|
||||
runtimeOptions: {
|
||||
timeoutSeconds:
|
||||
!isUndefined(localRuntimeOptions.timeoutSeconds) &&
|
||||
!(
|
||||
localRuntimeOptions.timeoutSeconds! > 0 &&
|
||||
localRuntimeOptions.timeoutSeconds! <= 540
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const [openMigrationGuide, setOpenMigrationGuide] = useState(false);
|
||||
useEffect(() => {
|
||||
if (tableSchema.sparks) setOpenMigrationGuide(true);
|
||||
@@ -57,7 +77,9 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
|
||||
} | null>(null);
|
||||
|
||||
const snackLogContext = useSnackLogContext();
|
||||
const edited = !isEqual(currentExtensionObjects, localExtensionsObjects);
|
||||
const edited =
|
||||
!isEqual(tableSchema.extensionObjects ?? [], localExtensionsObjects) ||
|
||||
!isEqual(tableSchema.runtimeOptions ?? {}, localRuntimeOptions);
|
||||
|
||||
const handleClose = (
|
||||
_setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
@@ -70,7 +92,8 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
|
||||
cancel: "Keep",
|
||||
handleConfirm: () => {
|
||||
_setOpen(false);
|
||||
setLocalExtensionsObjects(currentExtensionObjects);
|
||||
setLocalExtensionsObjects(tableSchema.extensionObjects ?? []);
|
||||
setLocalRuntimeOptions(tableSchema.runtimeOptions ?? {});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
@@ -79,15 +102,18 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveExtensions = async (callback?: Function) => {
|
||||
const handleSave = async (callback?: Function) => {
|
||||
if (updateTableSchema)
|
||||
await updateTableSchema({ extensionObjects: localExtensionsObjects });
|
||||
await updateTableSchema({
|
||||
extensionObjects: localExtensionsObjects,
|
||||
runtimeOptions: localRuntimeOptions,
|
||||
});
|
||||
if (callback) callback();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSaveDeploy = async () => {
|
||||
handleSaveExtensions(() => {
|
||||
handleSave(() => {
|
||||
try {
|
||||
snackLogContext.requestSnackLog();
|
||||
rowyRun({
|
||||
@@ -132,6 +158,13 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
|
||||
setExtensionModal(null);
|
||||
};
|
||||
|
||||
const handleUpdateRuntimeOptions = (update: IRuntimeOptions) => {
|
||||
setLocalRuntimeOptions((runtimeOptions) => ({
|
||||
...runtimeOptions,
|
||||
...update,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUpdateActive = (index: number, active: boolean) => {
|
||||
setLocalExtensionsObjects(
|
||||
localExtensionsObjects.map((extensionObject, i) => {
|
||||
@@ -217,24 +250,31 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) {
|
||||
/>
|
||||
}
|
||||
children={
|
||||
<ExtensionList
|
||||
extensions={localExtensionsObjects}
|
||||
handleUpdateActive={handleUpdateActive}
|
||||
handleEdit={handleEdit}
|
||||
handleDuplicate={handleDuplicate}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
<>
|
||||
<ExtensionList
|
||||
extensions={localExtensionsObjects}
|
||||
handleUpdateActive={handleUpdateActive}
|
||||
handleEdit={handleEdit}
|
||||
handleDuplicate={handleDuplicate}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
<RuntimeOptions
|
||||
runtimeOptions={localRuntimeOptions}
|
||||
handleUpdate={handleUpdateRuntimeOptions}
|
||||
errors={errors.runtimeOptions}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={{
|
||||
primary: {
|
||||
children: "Save & Deploy",
|
||||
onClick: handleSaveDeploy,
|
||||
disabled: !edited,
|
||||
disabled: !edited || errors.runtimeOptions.timeoutSeconds,
|
||||
},
|
||||
secondary: {
|
||||
children: "Save",
|
||||
onClick: () => handleSaveExtensions(),
|
||||
disabled: !edited,
|
||||
onClick: () => handleSave(),
|
||||
disabled: !edited || errors.runtimeOptions.timeoutSeconds,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
117
src/components/TableModals/ExtensionsModal/RuntimeOptions.tsx
Normal file
117
src/components/TableModals/ExtensionsModal/RuntimeOptions.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Button,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { ChevronDown } from "@src/assets/icons";
|
||||
import MultiSelect from "@rowy/multiselect";
|
||||
|
||||
import {
|
||||
compatibleRowyRunVersionAtom,
|
||||
projectScope,
|
||||
rowyRunModalAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
|
||||
import { IRuntimeOptions } from "./utils";
|
||||
|
||||
export default function RuntimeOptions({
|
||||
runtimeOptions,
|
||||
handleUpdate,
|
||||
errors,
|
||||
}: {
|
||||
runtimeOptions: IRuntimeOptions;
|
||||
handleUpdate: (runtimeOptions: IRuntimeOptions) => void;
|
||||
errors: { timeoutSeconds: boolean };
|
||||
}) {
|
||||
const [compatibleRowyRunVersion] = useAtom(
|
||||
compatibleRowyRunVersionAtom,
|
||||
projectScope
|
||||
);
|
||||
const openRowyRunModal = useSetAtom(rowyRunModalAtom, projectScope);
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isCompatibleRowyRun = compatibleRowyRunVersion({ minVersion: "1.6.4" });
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
sx={{
|
||||
padding: 0,
|
||||
boxShadow: "none",
|
||||
backgroundImage: "inherit",
|
||||
backgroundColor: "inherit",
|
||||
}}
|
||||
expanded={isCompatibleRowyRun && expanded}
|
||||
>
|
||||
<AccordionSummary
|
||||
sx={{ padding: 0 }}
|
||||
expandIcon={
|
||||
isCompatibleRowyRun ? (
|
||||
<ChevronDown />
|
||||
) : (
|
||||
<Button>Update Rowy Run</Button>
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
isCompatibleRowyRun
|
||||
? setExpanded(!expanded)
|
||||
: openRowyRunModal({
|
||||
version: "1.6.4",
|
||||
feature: "Runtime options",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Typography variant="subtitle1">Runtime options</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ padding: 0 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<MultiSelect
|
||||
label="Memory Allocated"
|
||||
value={runtimeOptions.memory ?? "256MB"}
|
||||
onChange={(value) => handleUpdate({ memory: value ?? "256MB" })}
|
||||
multiple={false}
|
||||
options={["128MB", "256MB", "512MB", "1GB", "2GB", "4GB", "8GB"]}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
value={runtimeOptions.timeoutSeconds ?? 60}
|
||||
label="Timeout"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">seconds</InputAdornment>
|
||||
),
|
||||
}}
|
||||
onChange={(e) =>
|
||||
!isNaN(Number(e.target.value)) &&
|
||||
handleUpdate({
|
||||
timeoutSeconds: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
inputProps={{
|
||||
inputMode: "numeric",
|
||||
}}
|
||||
error={errors.timeoutSeconds}
|
||||
helperText={
|
||||
errors.timeoutSeconds
|
||||
? "Timeout must be an integer between 1 and 540"
|
||||
: "The maximum timeout that can be specified is 9 mins (540 seconds)"
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,12 @@ export interface IExtension {
|
||||
trackedFields?: string[];
|
||||
}
|
||||
|
||||
// https://firebase.google.com/docs/functions/manage-functions#set_runtime_options
|
||||
export interface IRuntimeOptions {
|
||||
memory?: "128MB" | "256MB" | "512MB" | "1GB" | "2GB" | "4GB" | "8GB";
|
||||
timeoutSeconds?: number;
|
||||
}
|
||||
|
||||
export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"];
|
||||
|
||||
const extensionBodyTemplate = {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import WarningIcon from "@mui/icons-material/WarningAmber";
|
||||
import { TableSettings } from "@src/types/table";
|
||||
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
|
||||
import {
|
||||
IWebhook,
|
||||
ISecret,
|
||||
} from "@src/components/TableModals/WebhooksModal/utils";
|
||||
|
||||
const requestType = [
|
||||
"declare type WebHookRequest {",
|
||||
@@ -81,7 +84,11 @@ export const webhookBasic = {
|
||||
return true;
|
||||
}`,
|
||||
},
|
||||
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
|
||||
auth: (
|
||||
webhookObject: IWebhook,
|
||||
setWebhookObject: (w: IWebhook) => void,
|
||||
secrets: ISecret
|
||||
) => {
|
||||
return (
|
||||
<Typography color="text.disabled">
|
||||
<WarningIcon aria-label="Warning" style={{ verticalAlign: "bottom" }} />
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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";
|
||||
import {
|
||||
IWebhook,
|
||||
ISecret,
|
||||
} from "@src/components/TableModals/WebhooksModal/utils";
|
||||
|
||||
export const webhookSendgrid = {
|
||||
name: "SendGrid",
|
||||
@@ -37,7 +40,11 @@ export const webhookSendgrid = {
|
||||
return true;
|
||||
}`,
|
||||
},
|
||||
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
|
||||
auth: (
|
||||
webhookObject: IWebhook,
|
||||
setWebhookObject: (w: IWebhook) => void,
|
||||
secrets: ISecret
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
<Typography gutterBottom>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Typography, Link, TextField } from "@mui/material";
|
||||
import { Typography, Link, TextField, Alert } 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";
|
||||
import InputLabel from "@mui/material/InputLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import Select from "@mui/material/Select";
|
||||
|
||||
export const webhookStripe = {
|
||||
name: "Stripe",
|
||||
@@ -32,21 +39,19 @@ export const webhookStripe = {
|
||||
return true;
|
||||
}`,
|
||||
},
|
||||
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
|
||||
auth: (
|
||||
webhookObject: IWebhook,
|
||||
setWebhookObject: (w: IWebhook) => void,
|
||||
secrets: ISecret
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
<Typography gutterBottom>
|
||||
Get your{" "}
|
||||
<Link
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="inherit"
|
||||
>
|
||||
secret key
|
||||
<InlineOpenInNewIcon />
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
Select or add your secret key in the format of{" "}
|
||||
<code>
|
||||
{"{" + `"publicKey":"pk_...","secretKey": "sk_..."` + "}"}
|
||||
</code>{" "}
|
||||
and get your{" "}
|
||||
<Link
|
||||
href="https://dashboard.stripe.com/webhooks"
|
||||
target="_blank"
|
||||
@@ -61,19 +66,44 @@ export const webhookStripe = {
|
||||
Then add the secret below.
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
id="stripe-secret-key"
|
||||
label="Secret key"
|
||||
value={webhookObject.auth.secretKey}
|
||||
fullWidth
|
||||
multiline
|
||||
onChange={(e) => {
|
||||
setWebhookObject({
|
||||
...webhookObject,
|
||||
auth: { ...webhookObject.auth, secretKey: e.target.value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{webhookObject.auth.secretKey &&
|
||||
!secrets.loading &&
|
||||
!secrets.keys.includes(webhookObject.auth.secretKey) && (
|
||||
<Alert severity="error" sx={{ height: "auto!important" }}>
|
||||
Your previously selected key{" "}
|
||||
<code>{webhookObject.auth.secretKey}</code> does not exist in
|
||||
Secret Manager. Please select your key again.
|
||||
</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();
|
||||
}}
|
||||
>
|
||||
Add a key in Secret Manager
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
id="stripe-signing-secret"
|
||||
label="Signing key"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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";
|
||||
import {
|
||||
IWebhook,
|
||||
ISecret,
|
||||
} from "@src/components/TableModals/WebhooksModal/utils";
|
||||
|
||||
export const webhookTypeform = {
|
||||
name: "Typeform",
|
||||
@@ -75,7 +78,11 @@ export const webhookTypeform = {
|
||||
return true;
|
||||
}`,
|
||||
},
|
||||
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
|
||||
auth: (
|
||||
webhookObject: IWebhook,
|
||||
setWebhookObject: (w: IWebhook) => void,
|
||||
secrets: ISecret
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
<Typography gutterBottom>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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";
|
||||
import {
|
||||
IWebhook,
|
||||
ISecret,
|
||||
} from "@src/components/TableModals/WebhooksModal/utils";
|
||||
|
||||
export const webhook = {
|
||||
name: "Web Form",
|
||||
@@ -47,7 +50,11 @@ export const webhook = {
|
||||
return true;
|
||||
}`,
|
||||
},
|
||||
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
|
||||
auth: (
|
||||
webhookObject: IWebhook,
|
||||
setWebhookObject: (w: IWebhook) => void,
|
||||
secrets: ISecret
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
<Typography gutterBottom>
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { IWebhookModalStepProps } from "./WebhookModal";
|
||||
|
||||
import { FormControlLabel, Checkbox, Typography } from "@mui/material";
|
||||
|
||||
import { webhookSchemas } from "./utils";
|
||||
import {
|
||||
projectIdAtom,
|
||||
projectScope,
|
||||
rowyRunAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import { runRoutes } from "@src/constants/runRoutes";
|
||||
import { webhookSchemas, ISecret } 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>
|
||||
@@ -37,7 +65,8 @@ export default function Step1Endpoint({
|
||||
{webhookObject.auth?.enabled &&
|
||||
webhookSchemas[webhookObject.type].auth(
|
||||
webhookObject,
|
||||
setWebhookObject
|
||||
setWebhookObject,
|
||||
secrets
|
||||
)}
|
||||
{}
|
||||
</>
|
||||
|
||||
@@ -78,6 +78,12 @@ export interface IWebhook {
|
||||
auth?: any;
|
||||
}
|
||||
|
||||
export interface ISecret {
|
||||
loading: boolean;
|
||||
keys: string[];
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const webhookSchemas = {
|
||||
basic,
|
||||
typeform,
|
||||
|
||||
64
src/components/TableSettingsDialog/TableDetails.tsx
Normal file
64
src/components/TableSettingsDialog/TableDetails.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Box, InputLabel, useTheme } from "@mui/material";
|
||||
import MDEditor from "@uiw/react-md-editor";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function TableDetails({ ...props }) {
|
||||
const {
|
||||
field: { value, onChange },
|
||||
} = props;
|
||||
const theme = useTheme();
|
||||
const [focused, setFocused] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<InputLabel htmlFor="table-details__md-text-area" focused={focused}>
|
||||
{props.label ?? ""}
|
||||
</InputLabel>
|
||||
<Box
|
||||
data-color-mode={theme.palette.mode}
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
...theme.typography.body2,
|
||||
"& .w-md-editor": {
|
||||
backgroundColor: `${theme.palette.action.input} !important`,
|
||||
},
|
||||
"& .w-md-editor-fullscreen": {
|
||||
backgroundColor: `${theme.palette.background.paper} !important`,
|
||||
},
|
||||
"& .w-md-editor-toolbar": {
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
},
|
||||
"& .w-md-editor-toolbar > ul": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
"& .w-md-editor-toolbar > ul:first-of-type": {
|
||||
overflowX: "auto",
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
"& :is(h1, h2, h3, h4, h5, h6)": {
|
||||
marginY: `${theme.spacing(1.5)} !important`,
|
||||
borderBottom: "none !important",
|
||||
},
|
||||
"& details summary": {
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MDEditor
|
||||
style={{ margin: 1 }}
|
||||
preview="live"
|
||||
height={150}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
textareaProps={{
|
||||
id: "table-details__md-text-area",
|
||||
onFocus: () => setFocused(true),
|
||||
onBlur: () => setFocused(false),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,10 @@ import {
|
||||
getTableSchemaPath,
|
||||
getTableBuildFunctionPathname,
|
||||
} from "@src/utils/table";
|
||||
import { firebaseStorageAtom } from "@src/sources/ProjectSourceFirebase";
|
||||
import { uploadTableThumbnail } from "./utils";
|
||||
import TableThumbnail from "./TableThumbnail";
|
||||
import TableDetails from "./TableDetails";
|
||||
|
||||
const customComponents = {
|
||||
tableName: {
|
||||
@@ -55,6 +59,12 @@ const customComponents = {
|
||||
defaultValue: "",
|
||||
validation: [["string"]],
|
||||
},
|
||||
tableThumbnail: {
|
||||
component: TableThumbnail,
|
||||
},
|
||||
tableDetails: {
|
||||
component: TableDetails,
|
||||
},
|
||||
};
|
||||
|
||||
export default function TableSettingsDialog() {
|
||||
@@ -67,6 +77,7 @@ export default function TableSettingsDialog() {
|
||||
const [projectRoles] = useAtom(projectRolesAtom, projectScope);
|
||||
const [tables] = useAtom(tablesAtom, projectScope);
|
||||
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
|
||||
const [firebaseStorage] = useAtom(firebaseStorageAtom, projectScope);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
@@ -96,15 +107,26 @@ export default function TableSettingsDialog() {
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = async (v: TableSettings & AdditionalTableSettings) => {
|
||||
const handleSubmit = async (
|
||||
v: TableSettings & AdditionalTableSettings & { thumbnailFile: File }
|
||||
) => {
|
||||
const {
|
||||
_schemaSource,
|
||||
_initialColumns,
|
||||
_schema,
|
||||
_suggestedRules,
|
||||
thumbnailFile,
|
||||
...values
|
||||
} = v;
|
||||
const data = { ...values };
|
||||
|
||||
let thumbnailURL = values.thumbnailURL;
|
||||
if (thumbnailFile) {
|
||||
thumbnailURL = await uploadTableThumbnail(firebaseStorage)(
|
||||
values.id,
|
||||
thumbnailFile
|
||||
);
|
||||
}
|
||||
const data = { ...values, thumbnailURL };
|
||||
|
||||
const hasExtensions = !isEmpty(get(data, "_schema.extensionObjects"));
|
||||
const hasWebhooks = !isEmpty(get(data, "_schema.webhooks"));
|
||||
|
||||
126
src/components/TableSettingsDialog/TableThumbnail.tsx
Normal file
126
src/components/TableSettingsDialog/TableThumbnail.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { IFieldComponentProps } from "@rowy/form-builder";
|
||||
|
||||
import { Button, Grid, IconButton, InputLabel, useTheme } from "@mui/material";
|
||||
|
||||
import { Upload as UploadImageIcon } from "@src/assets/icons";
|
||||
import {
|
||||
OpenInFull as ExpandIcon,
|
||||
CloseFullscreen as CollapseIcon,
|
||||
AddPhotoAlternateOutlined as NoImageIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
import { IMAGE_MIME_TYPES } from "@src/components/fields/Image";
|
||||
|
||||
export default function TableThumbnail({ ...props }: IFieldComponentProps) {
|
||||
const {
|
||||
name,
|
||||
useFormMethods: { setValue, getValues },
|
||||
} = props;
|
||||
|
||||
const theme = useTheme();
|
||||
const [localImage, setLocalImage] = useState<string | undefined>(
|
||||
() => getValues().thumbnailURL
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const imageFile = acceptedFiles[0];
|
||||
|
||||
if (imageFile) {
|
||||
setLocalImage(URL.createObjectURL(imageFile));
|
||||
setValue(name, imageFile);
|
||||
}
|
||||
},
|
||||
[name, setLocalImage, setValue]
|
||||
);
|
||||
|
||||
const { getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
accept: IMAGE_MIME_TYPES,
|
||||
});
|
||||
|
||||
return (
|
||||
<Grid container>
|
||||
<Grid
|
||||
container
|
||||
alignItems="center"
|
||||
xs={expanded ? 12 : 10.5}
|
||||
sx={{
|
||||
marginRight: "auto",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
>
|
||||
<InputLabel htmlFor="thumbnail-image__input">{props.label}</InputLabel>
|
||||
<IconButton
|
||||
component="label"
|
||||
sx={{
|
||||
marginLeft: "auto",
|
||||
marginRight: expanded ? 0 : theme.spacing(0.5),
|
||||
}}
|
||||
>
|
||||
<UploadImageIcon />
|
||||
<input
|
||||
id="thumbnail-image__input"
|
||||
type="file"
|
||||
hidden
|
||||
{...getInputProps()}
|
||||
/>
|
||||
</IconButton>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={expanded ? 12 : 1.5}
|
||||
sx={{
|
||||
marginLeft: "auto",
|
||||
marginTop: expanded ? theme.spacing(1) : 0,
|
||||
transition: "all 0.5s",
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
sx={{
|
||||
position: "relative",
|
||||
// 16:9 ratio
|
||||
paddingBottom: "56.25%",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
disabled={!localImage}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundImage: `url("${localImage}")`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
"& > svg": {
|
||||
display: localImage ? "none" : "block",
|
||||
},
|
||||
"&:hover": {
|
||||
opacity: 0.75,
|
||||
},
|
||||
"&:hover > svg": {
|
||||
display: "block",
|
||||
},
|
||||
}}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{!localImage ? (
|
||||
<NoImageIcon />
|
||||
) : expanded ? (
|
||||
<CollapseIcon />
|
||||
) : (
|
||||
<ExpandIcon />
|
||||
)}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -215,8 +215,9 @@ export const tableSettings = (
|
||||
}.`,
|
||||
disabled: mode === "update",
|
||||
gridCols: { xs: 12, sm: 6 },
|
||||
validation:
|
||||
mode === "create"
|
||||
validation: [
|
||||
["matches", /^[^/]+$/g, "ID cannot have /"],
|
||||
...(mode === "create"
|
||||
? [
|
||||
[
|
||||
"test",
|
||||
@@ -225,7 +226,8 @@ export const tableSettings = (
|
||||
(value: any) => !find(tables, ["value", value]),
|
||||
],
|
||||
]
|
||||
: [],
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
step: "display",
|
||||
@@ -242,7 +244,23 @@ export const tableSettings = (
|
||||
type: FieldType.paragraph,
|
||||
name: "description",
|
||||
label: "Description (optional)",
|
||||
minRows: 2,
|
||||
},
|
||||
{
|
||||
step: "display",
|
||||
type: "tableDetails",
|
||||
name: "details",
|
||||
label: "Details (optional)",
|
||||
},
|
||||
{
|
||||
step: "display",
|
||||
type: "tableThumbnail",
|
||||
name: "thumbnailFile",
|
||||
label: "Thumbnail image (optional)",
|
||||
},
|
||||
{
|
||||
step: "display",
|
||||
type: FieldType.hidden,
|
||||
name: "thumbnailURL",
|
||||
},
|
||||
|
||||
// Step 3: Access controls
|
||||
|
||||
14
src/components/TableSettingsDialog/utils.ts
Normal file
14
src/components/TableSettingsDialog/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
FirebaseStorage,
|
||||
getDownloadURL,
|
||||
ref,
|
||||
uploadBytes,
|
||||
} from "firebase/storage";
|
||||
|
||||
export const uploadTableThumbnail =
|
||||
(storage: FirebaseStorage) => (tableId: string, imageFile: File) => {
|
||||
const storageRef = ref(storage, `__thumbnails__/${tableId}`);
|
||||
return uploadBytes(storageRef, imageFile).then(({ ref }) =>
|
||||
getDownloadURL(ref)
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Suspense, forwardRef } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { Offline, Online } from "react-detect-offline";
|
||||
|
||||
import { Tooltip, Typography, TypographyProps } from "@mui/material";
|
||||
import SyncIcon from "@mui/icons-material/Sync";
|
||||
import OfflineIcon from "@mui/icons-material/CloudOffOutlined";
|
||||
|
||||
import {
|
||||
tableScope,
|
||||
tableRowsAtom,
|
||||
tableNextPageAtom,
|
||||
serverDocCountAtom
|
||||
} from "@src/atoms/tableScope";
|
||||
import { spreadSx } from "@src/utils/ui";
|
||||
|
||||
const StatusText = forwardRef(function StatusText(
|
||||
props: TypographyProps,
|
||||
@@ -20,29 +25,53 @@ const StatusText = forwardRef(function StatusText(
|
||||
color="text.disabled"
|
||||
display="block"
|
||||
{...props}
|
||||
style={{ userSelect: "none", ...props.style }}
|
||||
sx={[
|
||||
{
|
||||
userSelect: "none",
|
||||
|
||||
"& svg": {
|
||||
fontSize: 20,
|
||||
width: "1em",
|
||||
height: "1em",
|
||||
verticalAlign: "bottom",
|
||||
display: "inline-block",
|
||||
mr: 0.75,
|
||||
},
|
||||
},
|
||||
...spreadSx(props.sx),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function LoadedRowsStatus() {
|
||||
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
|
||||
const loadingIcon = (
|
||||
<SyncIcon
|
||||
sx={{
|
||||
animation: "spin-infinite 1.5s linear infinite",
|
||||
"@keyframes spin-infinite": {
|
||||
from: { transform: "rotate(45deg)" },
|
||||
to: { transform: `rotate(${45 - 360}deg)` },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
function LoadedRowsStatus() {
|
||||
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
|
||||
const [serverDocCount] = useAtom(serverDocCountAtom, tableScope)
|
||||
const [tableRows] = useAtom(tableRowsAtom, tableScope)
|
||||
|
||||
|
||||
if (tableNextPage.loading)
|
||||
return <StatusText>{loadingIcon}Loading more…</StatusText>;
|
||||
|
||||
if (tableNextPage.loading) return <StatusText>Loading more…</StatusText>;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
tableNextPage.available
|
||||
? "Scroll to the bottom to load more rows"
|
||||
: "All rows have been loaded in this table"
|
||||
}
|
||||
describeChild
|
||||
>
|
||||
<Tooltip title="Syncing with database in realtime" describeChild>
|
||||
<StatusText>
|
||||
<SyncIcon style={{ transform: "rotate(45deg)" }} />
|
||||
Loaded {!tableNextPage.available && "all "}
|
||||
{tableRows.length} row{tableRows.length !== 1 && "s"}
|
||||
{tableRows.length} {tableNextPage.available && `of ${serverDocCount}`} row{serverDocCount !== 1 && "s"}
|
||||
</StatusText>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -50,8 +79,21 @@ function LoadedRowsStatus() {
|
||||
|
||||
export default function SuspendedLoadedRowsStatus() {
|
||||
return (
|
||||
<Suspense fallback={<StatusText>Loading…</StatusText>}>
|
||||
<LoadedRowsStatus />
|
||||
</Suspense>
|
||||
<>
|
||||
<Online>
|
||||
<Suspense fallback={<StatusText>{loadingIcon}Loading…</StatusText>}>
|
||||
<LoadedRowsStatus />
|
||||
</Suspense>
|
||||
</Online>
|
||||
|
||||
<Offline>
|
||||
<Tooltip title="Changes will be saved when you reconnect" describeChild>
|
||||
<StatusText color="error.main">
|
||||
<OfflineIcon />
|
||||
Offline
|
||||
</StatusText>
|
||||
</Tooltip>
|
||||
</Offline>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
25
src/components/TableToolbar/TableInformation.tsx
Normal file
25
src/components/TableToolbar/TableInformation.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { RESET } from "jotai/utils";
|
||||
|
||||
import TableToolbarButton from "@src/components/TableToolbar/TableToolbarButton";
|
||||
import InfoIcon from "@mui/icons-material/InfoOutlined";
|
||||
|
||||
import {
|
||||
sideDrawerAtom,
|
||||
tableScope,
|
||||
tableSettingsAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
|
||||
export default function TableInformation() {
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
const [sideDrawer, setSideDrawer] = useAtom(sideDrawerAtom, tableScope);
|
||||
|
||||
return (
|
||||
<TableToolbarButton
|
||||
title="Table information"
|
||||
icon={<InfoIcon />}
|
||||
onClick={() => setSideDrawer(sideDrawer ? RESET : "table-information")}
|
||||
disabled={!setSideDrawer || tableSettings.id.includes("/")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import LoadedRowsStatus from "./LoadedRowsStatus";
|
||||
import TableSettings from "./TableSettings";
|
||||
import HiddenFields from "./HiddenFields";
|
||||
import RowHeight from "./RowHeight";
|
||||
import TableInformation from "./TableInformation";
|
||||
|
||||
import {
|
||||
projectScope,
|
||||
@@ -36,6 +37,7 @@ import { FieldType } from "@src/constants/fields";
|
||||
const Filters = lazy(() => import("./Filters" /* webpackChunkName: "Filters" */));
|
||||
// prettier-ignore
|
||||
const ImportData = lazy(() => import("./ImportData/ImportData" /* webpackChunkName: "ImportData" */));
|
||||
|
||||
// prettier-ignore
|
||||
const ReExecute = lazy(() => import("./ReExecute" /* webpackChunkName: "ReExecute" */));
|
||||
|
||||
@@ -147,6 +149,7 @@ export default function TableToolbar() {
|
||||
<TableSettings />
|
||||
</>
|
||||
)}
|
||||
<TableInformation />
|
||||
<div className="end-spacer" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -4,27 +4,38 @@ import { Tooltip, Button, ButtonProps } from "@mui/material";
|
||||
export interface ITableToolbarButtonProps extends Partial<ButtonProps> {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export const TableToolbarButton = forwardRef(function TableToolbarButton_(
|
||||
{ title, icon, ...props }: ITableToolbarButtonProps,
|
||||
{ title, icon, tooltip, ...props }: ITableToolbarButtonProps,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
// https://mui.com/material-ui/react-tooltip/#accessibility
|
||||
const tooltipIsDescription = Boolean(tooltip);
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="small"
|
||||
style={{ minWidth: 40, height: 32, padding: 0 }}
|
||||
{...props}
|
||||
{...(tooltipIsDescription
|
||||
? {
|
||||
"aria-label": title, // Actual button label
|
||||
title: tooltip, // Tooltip text, used to describe button e.g. why it’s disabled
|
||||
}
|
||||
: {})}
|
||||
ref={ref}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="small"
|
||||
style={{ minWidth: 40, height: 32, padding: 0 }}
|
||||
aria-label={title}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</span>
|
||||
<Tooltip title={tooltip || title} describeChild={tooltipIsDescription}>
|
||||
{props.disabled ? <span title="">{button}</span> : button}
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,10 +7,9 @@ import {
|
||||
Typography,
|
||||
CardActions,
|
||||
Button,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { Go as GoIcon } from "@src/assets/icons";
|
||||
|
||||
import RenderedMarkdown from "@src/components/RenderedMarkdown";
|
||||
import { TableSettings } from "@src/types/table";
|
||||
|
||||
export interface ITableCardProps extends TableSettings {
|
||||
@@ -19,6 +18,7 @@ export interface ITableCardProps extends TableSettings {
|
||||
}
|
||||
|
||||
export default function TableCard({
|
||||
thumbnailURL,
|
||||
section,
|
||||
name,
|
||||
description,
|
||||
@@ -37,26 +37,43 @@ export default function TableCard({
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
|
||||
<CardContent style={{ flexGrow: 1, paddingTop: 0 }}>
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
sx={{
|
||||
minHeight: (theme) =>
|
||||
(theme.typography.body2.lineHeight as number) * 2 + "em",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
component="div"
|
||||
>
|
||||
{description && (
|
||||
<RenderedMarkdown
|
||||
children={description}
|
||||
//restrictionPreset="singleLine"
|
||||
{thumbnailURL && (
|
||||
<Box
|
||||
sx={{
|
||||
paddingBottom: "56.25%",
|
||||
position: "relative",
|
||||
backgroundColor: "action.input",
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundImage: `url("${thumbnailURL}")`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{description && (
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
sx={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
component="div"
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardActions>
|
||||
|
||||
@@ -303,7 +303,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
aria-label="Action will run"
|
||||
name="isActionScript"
|
||||
value={
|
||||
config.isActionScript ? "actionScript" : "cloudFunction"
|
||||
config.isActionScript !== false ? "actionScript" : "cloudFunction"
|
||||
}
|
||||
onChange={(e) =>
|
||||
onChange("isActionScript")(
|
||||
@@ -359,7 +359,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{!config.isActionScript ? (
|
||||
{config.isActionScript === false ? (
|
||||
<TextField
|
||||
id="callableName"
|
||||
label="Callable name"
|
||||
@@ -492,7 +492,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
config.isActionScript &&
|
||||
config.isActionScript !== false &&
|
||||
get(config, "undo.enabled") && {
|
||||
id: "undo",
|
||||
title: "Undo action",
|
||||
@@ -559,6 +559,32 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
title: "Customization",
|
||||
content: (
|
||||
<>
|
||||
<Stack>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={config.customName?.enabled}
|
||||
onChange={(e) =>
|
||||
onChange("customName.enabled")(e.target.checked)
|
||||
}
|
||||
name="customName.enabled"
|
||||
/>
|
||||
}
|
||||
label="Customize label for action"
|
||||
style={{ marginLeft: -11 }}
|
||||
/>
|
||||
{config.customName?.enabled && (
|
||||
<TextField
|
||||
id="customName.actionName"
|
||||
value={get(config, "customName.actionName")}
|
||||
onChange={(e) =>
|
||||
onChange("customName.actionName")(e.target.value)
|
||||
}
|
||||
label="Action name:"
|
||||
className="labelHorizontal"
|
||||
inputProps={{ style: { width: "10ch" } }}
|
||||
></TextField>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
@@ -572,7 +598,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
label="Customize button icons with emoji"
|
||||
style={{ marginLeft: -11 }}
|
||||
/>
|
||||
|
||||
</Stack>
|
||||
{config.customIcons?.enabled && (
|
||||
<Grid container spacing={2} sx={{ mt: { xs: 0, sm: -1 } }}>
|
||||
<Grid item xs={12} sm={true}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import ActionFab from "./ActionFab";
|
||||
import { tableScope, tableRowsAtom } from "@src/atoms/tableScope";
|
||||
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
|
||||
import { sanitiseCallableName, isUrl } from "./utils";
|
||||
import { getActionName } from "./TableCell"
|
||||
|
||||
export default function Action({
|
||||
column,
|
||||
@@ -60,7 +61,7 @@ export default function Action({
|
||||
) : hasRan ? (
|
||||
value.status
|
||||
) : (
|
||||
sanitiseCallableName(column.key)
|
||||
sanitiseCallableName(getActionName(column))
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ import { Stack } from "@mui/material";
|
||||
|
||||
import ActionFab from "./ActionFab";
|
||||
import { sanitiseCallableName, isUrl } from "./utils";
|
||||
import { get } from "lodash-es";
|
||||
|
||||
|
||||
export const getActionName = (column: any) => {
|
||||
const config = get(column, "config")
|
||||
if (!get(config, "customName.enabled")) { return get(column, "name") }
|
||||
return get(config, "customName.actionName") || get(column, "name");
|
||||
};
|
||||
|
||||
export default function Action({
|
||||
column,
|
||||
@@ -29,7 +37,7 @@ export default function Action({
|
||||
) : hasRan ? (
|
||||
value.status
|
||||
) : (
|
||||
sanitiseCallableName(column.key)
|
||||
sanitiseCallableName(getActionName(column))
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
21
src/components/fields/Color/filters.ts
Normal file
21
src/components/fields/Color/filters.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { IFilterOperator } from "@src/components/fields/types";
|
||||
|
||||
export const filterOperators: IFilterOperator[] = [
|
||||
{
|
||||
label: "is",
|
||||
secondaryLabel: "==",
|
||||
value: "color-equal"
|
||||
},
|
||||
{
|
||||
label: "is not",
|
||||
secondaryLabel: "!=",
|
||||
value: "color-not-equal"
|
||||
}
|
||||
];
|
||||
|
||||
export const valueFormatter = (value: any) => {
|
||||
if (value && value.hex) {
|
||||
return value.hex.toString()
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import ColorIcon from "@mui/icons-material/Colorize";
|
||||
import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull";
|
||||
import InlineCell from "./InlineCell";
|
||||
import NullEditor from "@src/components/Table/editors/NullEditor";
|
||||
import { filterOperators, valueFormatter } from "./filters";
|
||||
|
||||
const PopoverCell = lazy(
|
||||
() => import("./PopoverCell" /* webpackChunkName: "PopoverCell-Color" */)
|
||||
@@ -31,6 +32,10 @@ export const config: IFieldConfig = {
|
||||
}),
|
||||
TableEditor: NullEditor as any,
|
||||
SideDrawerField,
|
||||
filter: {
|
||||
operators: filterOperators,
|
||||
valueFormatter
|
||||
},
|
||||
csvImportParser: (value: string) => {
|
||||
try {
|
||||
const obj = JSON.parse(value);
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { ISideDrawerFieldProps } from "@src/components/fields/types";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import useUploader from "@src/hooks/useFirebaseStorageUploader";
|
||||
|
||||
import {
|
||||
alpha,
|
||||
ButtonBase,
|
||||
@@ -15,69 +10,33 @@ import {
|
||||
Chip,
|
||||
} from "@mui/material";
|
||||
import { Upload as UploadIcon } from "@src/assets/icons";
|
||||
import { FileIcon } from ".";
|
||||
|
||||
import { ISideDrawerFieldProps } from "@src/components/fields/types";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||
|
||||
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
|
||||
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import useFileUpload from "./useFileUpload";
|
||||
import { FileIcon } from ".";
|
||||
|
||||
export default function File_({
|
||||
column,
|
||||
_rowy_ref,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled,
|
||||
}: ISideDrawerFieldProps) {
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
const { loading, progress, handleDelete, localFiles, dropzoneState } =
|
||||
useFileUpload(_rowy_ref, column.key, { multiple: true });
|
||||
|
||||
const { uploaderState, upload, deleteUpload } = useUploader();
|
||||
|
||||
// Store a preview image locally while uploading
|
||||
const [localFile, setLocalFile] = useState<string>("");
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
|
||||
if (_rowy_ref && file) {
|
||||
upload({
|
||||
docRef: _rowy_ref! as any,
|
||||
fieldName: column.key,
|
||||
files: [file],
|
||||
previousValue: value ?? [],
|
||||
onComplete: (newValue) => {
|
||||
onChange(newValue);
|
||||
onSubmit();
|
||||
setLocalFile("");
|
||||
},
|
||||
});
|
||||
setLocalFile(file.name);
|
||||
}
|
||||
},
|
||||
[_rowy_ref, value]
|
||||
);
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
const newValue = [...value];
|
||||
const toBeDeleted = newValue.splice(index, 1);
|
||||
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
|
||||
onChange(newValue);
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
});
|
||||
const { isDragActive, getRootProps, getInputProps } = dropzoneState;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!disabled && (
|
||||
<ButtonBase
|
||||
disabled={loading}
|
||||
sx={[
|
||||
fieldSx,
|
||||
{
|
||||
@@ -101,13 +60,21 @@ export default function File_({
|
||||
<Typography color="inherit" style={{ flexGrow: 1 }}>
|
||||
Click to upload or drop file here
|
||||
</Typography>
|
||||
<UploadIcon sx={{ ml: 1, mr: 2 / 8 }} />
|
||||
{loading ? (
|
||||
<CircularProgressOptical
|
||||
size={20}
|
||||
variant={progress === 0 ? "indeterminate" : "determinate"}
|
||||
value={progress}
|
||||
/>
|
||||
) : (
|
||||
<UploadIcon sx={{ ml: 1, mr: 2 / 8 }} />
|
||||
)}
|
||||
</ButtonBase>
|
||||
)}
|
||||
|
||||
<Grid container spacing={0.5} style={{ marginTop: 2 }}>
|
||||
{Array.isArray(value) &&
|
||||
value.map((file: FileValue, i) => (
|
||||
value.map((file: FileValue) => (
|
||||
<Grid item key={file.name}>
|
||||
<Tooltip
|
||||
title={`File last modified ${format(
|
||||
@@ -128,7 +95,7 @@ export default function File_({
|
||||
body: "This file cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: () => handleDelete(i),
|
||||
handleConfirm: () => handleDelete(file),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
@@ -138,15 +105,18 @@ export default function File_({
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
{localFile && (
|
||||
<Grid item>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={localFile}
|
||||
deleteIcon={<CircularProgressOptical size={20} color="inherit" />}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{localFiles &&
|
||||
localFiles.map((file) => (
|
||||
<Grid item>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
deleteIcon={
|
||||
<CircularProgressOptical size={20} color="inherit" />
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { IHeavyCellProps } from "@src/components/fields/types";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { findIndex } from "lodash-es";
|
||||
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { alpha, Stack, Grid, Tooltip, Chip, IconButton } from "@mui/material";
|
||||
@@ -12,64 +9,24 @@ import ChipList from "@src/components/Table/formatters/ChipList";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
|
||||
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
|
||||
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
|
||||
import useUploader from "@src/hooks/useFirebaseStorageUploader";
|
||||
import { FileIcon } from ".";
|
||||
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import useFileUpload from "./useFileUpload";
|
||||
|
||||
export default function File_({
|
||||
column,
|
||||
row,
|
||||
value,
|
||||
onSubmit,
|
||||
disabled,
|
||||
docRef,
|
||||
}: IHeavyCellProps) {
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
|
||||
const { uploaderState, upload, deleteUpload } = useUploader();
|
||||
const { progress, isLoading } = uploaderState;
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
|
||||
if (file) {
|
||||
upload({
|
||||
docRef: docRef! as any,
|
||||
fieldName: column.key,
|
||||
files: [file],
|
||||
previousValue: value,
|
||||
onComplete: (newValue) => {
|
||||
updateField({
|
||||
path: docRef.path,
|
||||
fieldName: column.key,
|
||||
value: newValue,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
const handleDelete = (ref: string) => {
|
||||
const newValue = [...value];
|
||||
const index = findIndex(newValue, ["ref", ref]);
|
||||
const toBeDeleted = newValue.splice(index, 1);
|
||||
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
|
||||
onSubmit(newValue);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
});
|
||||
const { loading, progress, handleDelete, localFiles, dropzoneState } =
|
||||
useFileUpload(docRef, column.key, { multiple: true });
|
||||
|
||||
const { isDragActive, getRootProps, getInputProps } = dropzoneState;
|
||||
const dropzoneProps = getRootProps();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -112,8 +69,13 @@ export default function File_({
|
||||
)}`}
|
||||
>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
icon={<FileIcon />}
|
||||
sx={{
|
||||
"& .MuiChip-label": {
|
||||
lineHeight: 5 / 3,
|
||||
},
|
||||
}}
|
||||
onClick={(e) => {
|
||||
window.open(file.downloadURL);
|
||||
e.stopPropagation();
|
||||
@@ -123,21 +85,32 @@ export default function File_({
|
||||
? undefined
|
||||
: () =>
|
||||
confirm({
|
||||
handleConfirm: () => handleDelete(file.ref),
|
||||
handleConfirm: () => handleDelete(file),
|
||||
title: "Delete file?",
|
||||
body: "This file cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
})
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
))}
|
||||
{localFiles &&
|
||||
localFiles.map((file) => (
|
||||
<Grid item>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
deleteIcon={
|
||||
<CircularProgressOptical size={20} color="inherit" />
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</ChipList>
|
||||
|
||||
{!isLoading ? (
|
||||
{!loading ? (
|
||||
!disabled && (
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
87
src/components/fields/File/useFileUpload.ts
Normal file
87
src/components/fields/File/useFileUpload.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { some } from "lodash-es";
|
||||
|
||||
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
|
||||
import useUploader from "@src/hooks/useFirebaseStorageUploader";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import { DropzoneOptions, useDropzone } from "react-dropzone";
|
||||
|
||||
export default function useFileUpload(
|
||||
docRef: any,
|
||||
fieldName: string,
|
||||
dropzoneOptions: DropzoneOptions = {}
|
||||
) {
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
const { uploaderState, upload, deleteUpload } = useUploader();
|
||||
|
||||
const [localFiles, setLocalFiles] = useState<File[]>([]);
|
||||
|
||||
const dropzoneState = useDropzone({
|
||||
onDrop: async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
setLocalFiles(acceptedFiles);
|
||||
await handleUpload(acceptedFiles);
|
||||
setLocalFiles([]);
|
||||
}
|
||||
},
|
||||
...dropzoneOptions,
|
||||
});
|
||||
|
||||
const uploadingFiles = Object.keys(uploaderState);
|
||||
|
||||
const progress =
|
||||
uploadingFiles.length > 0
|
||||
? uploadingFiles.reduce((sum, fileName) => {
|
||||
const fileState = uploaderState[fileName];
|
||||
return sum + fileState.progress;
|
||||
}, 0) / uploadingFiles.length
|
||||
: 0;
|
||||
|
||||
const loading = some(
|
||||
uploadingFiles,
|
||||
(fileName) => uploaderState[fileName].loading
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (files: File[]) => {
|
||||
const { uploads, failures } = await upload({
|
||||
docRef,
|
||||
fieldName,
|
||||
files,
|
||||
});
|
||||
updateField({
|
||||
path: docRef.path,
|
||||
fieldName,
|
||||
value: uploads,
|
||||
useArrayUnion: true,
|
||||
});
|
||||
return { uploads, failures };
|
||||
},
|
||||
[docRef, fieldName, updateField, upload]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(file: FileValue) => {
|
||||
updateField({
|
||||
path: docRef.path,
|
||||
fieldName,
|
||||
value: [file],
|
||||
useArrayRemove: true,
|
||||
disableCheckEquality: true,
|
||||
});
|
||||
deleteUpload(file);
|
||||
},
|
||||
[deleteUpload, docRef, fieldName, updateField]
|
||||
);
|
||||
|
||||
return {
|
||||
localFiles,
|
||||
progress,
|
||||
loading,
|
||||
uploaderState,
|
||||
handleUpload,
|
||||
handleDelete,
|
||||
dropzoneState,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { ISideDrawerFieldProps } from "@src/components/fields/types";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
import { useDropzone } from "react-dropzone";
|
||||
// TODO: GENERALIZE
|
||||
import useUploader from "@src/hooks/useFirebaseStorageUploader";
|
||||
import { assignIn } from "lodash-es";
|
||||
|
||||
import {
|
||||
alpha,
|
||||
@@ -20,12 +17,14 @@ import AddIcon from "@mui/icons-material/AddAPhotoOutlined";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import OpenIcon from "@mui/icons-material/OpenInNewOutlined";
|
||||
|
||||
import { FileValue } from "@src/types/table";
|
||||
import Thumbnail from "@src/components/Thumbnail";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
|
||||
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
|
||||
import { IMAGE_MIME_TYPES } from ".";
|
||||
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
|
||||
import useFileUpload from "@src/components/fields/File/useFileUpload";
|
||||
import { IMAGE_MIME_TYPES } from ".";
|
||||
|
||||
const imgSx = {
|
||||
position: "relative",
|
||||
@@ -84,57 +83,37 @@ export default function Image_({
|
||||
column,
|
||||
_rowy_ref,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled,
|
||||
}: ISideDrawerFieldProps) {
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
const { uploaderState, upload, deleteUpload } = useUploader();
|
||||
const { progress } = uploaderState;
|
||||
|
||||
// Store a preview image locally while uploading
|
||||
const [localImage, setLocalImage] = useState<string>("");
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const imageFile = acceptedFiles[0];
|
||||
|
||||
if (_rowy_ref && imageFile) {
|
||||
upload({
|
||||
docRef: _rowy_ref! as any,
|
||||
fieldName: column.key,
|
||||
files: [imageFile],
|
||||
previousValue: value ?? [],
|
||||
onComplete: (newValue) => {
|
||||
onChange(newValue);
|
||||
onSubmit();
|
||||
setLocalImage("");
|
||||
},
|
||||
});
|
||||
setLocalImage(URL.createObjectURL(imageFile));
|
||||
}
|
||||
},
|
||||
[_rowy_ref, value]
|
||||
);
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
const newValue = [...value];
|
||||
const toBeDeleted = newValue.splice(index, 1);
|
||||
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
|
||||
onChange(newValue);
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
const {
|
||||
loading,
|
||||
progress,
|
||||
handleDelete,
|
||||
uploaderState,
|
||||
localFiles,
|
||||
dropzoneState,
|
||||
} = useFileUpload(_rowy_ref, column.key, {
|
||||
multiple: true,
|
||||
accept: IMAGE_MIME_TYPES,
|
||||
});
|
||||
|
||||
const localImages = useMemo(
|
||||
() =>
|
||||
localFiles.map((file) =>
|
||||
assignIn(file, { localURL: URL.createObjectURL(file) })
|
||||
),
|
||||
[localFiles]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!disabled && (
|
||||
<ButtonBase
|
||||
disabled={loading}
|
||||
sx={[
|
||||
fieldSx,
|
||||
{
|
||||
@@ -160,14 +139,22 @@ export default function Image_({
|
||||
? "Drop image here"
|
||||
: "Click to upload or drop image here"}
|
||||
</Typography>
|
||||
<AddIcon sx={{ ml: 1, mr: 2 / 8 }} />
|
||||
{loading ? (
|
||||
<CircularProgressOptical
|
||||
size={20}
|
||||
variant={progress === 0 ? "indeterminate" : "determinate"}
|
||||
value={progress}
|
||||
/>
|
||||
) : (
|
||||
<AddIcon sx={{ ml: 1, mr: 2 / 8 }} />
|
||||
)}
|
||||
</ButtonBase>
|
||||
)}
|
||||
|
||||
<Grid container spacing={1} style={{ marginTop: 0 }}>
|
||||
{Array.isArray(value) &&
|
||||
value.map((image, i) => (
|
||||
<Grid item key={image.downloadURL}>
|
||||
value.map((image: FileValue) => (
|
||||
<Grid item key={image.name}>
|
||||
{disabled ? (
|
||||
<Tooltip title="Open">
|
||||
<ButtonBase
|
||||
@@ -214,7 +201,7 @@ export default function Image_({
|
||||
body: "This image cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: () => handleDelete(i),
|
||||
handleConfirm: () => handleDelete(image),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -237,29 +224,38 @@ export default function Image_({
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
{localImage && (
|
||||
<Grid item>
|
||||
<ButtonBase
|
||||
sx={imgSx}
|
||||
style={{ backgroundImage: `url("${localImage}")` }}
|
||||
className="img"
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={overlaySx}
|
||||
{localImages &&
|
||||
localImages.map((image) => (
|
||||
<Grid item key={image.name}>
|
||||
<ButtonBase
|
||||
sx={imgSx}
|
||||
style={{
|
||||
backgroundImage: `url("${image.localURL}")`,
|
||||
}}
|
||||
className="img"
|
||||
>
|
||||
<CircularProgressOptical
|
||||
color="inherit"
|
||||
size={48}
|
||||
variant={progress === 0 ? "indeterminate" : "determinate"}
|
||||
value={progress}
|
||||
/>
|
||||
</Grid>
|
||||
</ButtonBase>
|
||||
</Grid>
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { IHeavyCellProps } from "@src/components/fields/types";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { findIndex } from "lodash-es";
|
||||
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { assignIn } from "lodash-es";
|
||||
|
||||
import {
|
||||
alpha,
|
||||
@@ -23,15 +21,11 @@ import Thumbnail from "@src/components/Thumbnail";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
|
||||
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
|
||||
import {
|
||||
tableSchemaAtom,
|
||||
tableScope,
|
||||
updateFieldAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
import useUploader from "@src/hooks/useFirebaseStorageUploader";
|
||||
import { IMAGE_MIME_TYPES } from "./index";
|
||||
import { tableSchemaAtom, tableScope } from "@src/atoms/tableScope";
|
||||
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import useFileUpload from "@src/components/fields/File/useFileUpload";
|
||||
import { IMAGE_MIME_TYPES } from "./index";
|
||||
|
||||
// MULTIPLE
|
||||
const imgSx = (rowHeight: number) => ({
|
||||
@@ -88,57 +82,27 @@ const deleteImgHoverSx = {
|
||||
export default function Image_({
|
||||
column,
|
||||
value,
|
||||
onSubmit,
|
||||
disabled,
|
||||
docRef,
|
||||
}: IHeavyCellProps) {
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
const { uploaderState, upload, deleteUpload } = useUploader();
|
||||
const { progress, isLoading } = uploaderState;
|
||||
|
||||
// Store a preview image locally while uploading
|
||||
const [localImage, setLocalImage] = useState<string>("");
|
||||
const { loading, progress, handleDelete, localFiles, dropzoneState } =
|
||||
useFileUpload(docRef, column.key, {
|
||||
multiple: true,
|
||||
accept: IMAGE_MIME_TYPES,
|
||||
});
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const imageFile = acceptedFiles[0];
|
||||
|
||||
if (imageFile) {
|
||||
upload({
|
||||
docRef: docRef! as any,
|
||||
fieldName: column.key,
|
||||
files: [imageFile],
|
||||
previousValue: value,
|
||||
onComplete: (newValue) => {
|
||||
updateField({
|
||||
path: docRef.path,
|
||||
fieldName: column.key,
|
||||
value: newValue,
|
||||
});
|
||||
setLocalImage("");
|
||||
},
|
||||
});
|
||||
setLocalImage(URL.createObjectURL(imageFile));
|
||||
}
|
||||
},
|
||||
[value]
|
||||
const localImages = useMemo(
|
||||
() =>
|
||||
localFiles.map((file) =>
|
||||
assignIn(file, { localURL: URL.createObjectURL(file) })
|
||||
),
|
||||
[localFiles]
|
||||
);
|
||||
|
||||
const handleDelete = (index: number) => () => {
|
||||
const newValue = [...value];
|
||||
const toBeDeleted = newValue.splice(index, 1);
|
||||
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
|
||||
onSubmit(newValue);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
accept: IMAGE_MIME_TYPES,
|
||||
});
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
|
||||
const dropzoneProps = getRootProps();
|
||||
|
||||
const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT;
|
||||
@@ -183,17 +147,17 @@ export default function Image_({
|
||||
>
|
||||
<Grid container spacing={0.5} wrap="nowrap">
|
||||
{Array.isArray(value) &&
|
||||
value.map((file: FileValue, i) => (
|
||||
<Grid item key={file.downloadURL}>
|
||||
value.map((image: FileValue) => (
|
||||
<Grid item key={image.downloadURL}>
|
||||
{disabled ? (
|
||||
<Tooltip title="Open">
|
||||
<ButtonBase
|
||||
sx={imgSx(rowHeight)}
|
||||
className="img"
|
||||
onClick={() => window.open(file.downloadURL, "_blank")}
|
||||
onClick={() => window.open(image.downloadURL, "_blank")}
|
||||
>
|
||||
<Thumbnail
|
||||
imageUrl={file.downloadURL}
|
||||
imageUrl={image.downloadURL}
|
||||
size={thumbnailSize}
|
||||
objectFit="contain"
|
||||
sx={thumbnailSx}
|
||||
@@ -224,12 +188,12 @@ export default function Image_({
|
||||
body: "This image cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: handleDelete(i),
|
||||
handleConfirm: () => handleDelete(image),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Thumbnail
|
||||
imageUrl={file.downloadURL}
|
||||
imageUrl={image.downloadURL}
|
||||
size={thumbnailSize}
|
||||
objectFit="contain"
|
||||
sx={thumbnailSx}
|
||||
@@ -249,24 +213,27 @@ export default function Image_({
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
{localImage && (
|
||||
<Grid item>
|
||||
<Box
|
||||
sx={[
|
||||
imgSx(rowHeight),
|
||||
{
|
||||
boxShadow: (theme) =>
|
||||
`0 0 0 1px ${theme.palette.divider} inset`,
|
||||
},
|
||||
]}
|
||||
style={{ backgroundImage: `url("${localImage}")` }}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{!isLoading ? (
|
||||
{!loading ? (
|
||||
!disabled && (
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
@@ -40,13 +40,14 @@ export default function Json({
|
||||
const [editor, setEditor] = useAtom(jsonEditorAtom, projectScope);
|
||||
const [codeValid, setCodeValid] = useState(true);
|
||||
|
||||
const sanitizedValue =
|
||||
const baseValue =
|
||||
value !== undefined && isValidJson(value)
|
||||
? value
|
||||
: column.config?.isArray
|
||||
? []
|
||||
: {};
|
||||
const formattedJson = stringify(sanitizedValue, { space: 2 });
|
||||
const formattedJson = stringify(baseValue, { space: 2 });
|
||||
const sanitizedValue = JSON.parse(formattedJson);
|
||||
|
||||
if (disabled)
|
||||
return (
|
||||
|
||||
28
src/components/fields/Rating/Icon.tsx
Normal file
28
src/components/fields/Rating/Icon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import RatingIcon from "@mui/icons-material/Star";
|
||||
import RatingOutlineIcon from "@mui/icons-material/StarBorder"
|
||||
import { get } from "lodash-es";
|
||||
|
||||
|
||||
export interface IIconProps{
|
||||
config: any,
|
||||
isEmpty: boolean
|
||||
}
|
||||
|
||||
export default function Icon({config, isEmpty} : IIconProps) {
|
||||
if (isEmpty) {
|
||||
return getStateOutline(config)
|
||||
} else {
|
||||
return getStateIcon(config)
|
||||
}
|
||||
}
|
||||
|
||||
const getStateIcon = (config: any) => {
|
||||
// only use the config to get the custom rating icon if enabled via toggle
|
||||
if (!get(config, "customIcons.enabled")) { return <RatingIcon /> }
|
||||
console.log(get(config, "customIcons.rating"))
|
||||
return get(config, "customIcons.rating") || <RatingIcon />;
|
||||
};
|
||||
const getStateOutline = (config: any) => {
|
||||
if (!get(config, "customIcons.enabled")) { return <RatingOutlineIcon /> }
|
||||
return get(config, "customIcons.rating") || <RatingOutlineIcon />;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ISettingsProps } from "@src/components/fields/types";
|
||||
import RatingIcon from "@mui/icons-material/Star";
|
||||
import RatingOutlineIcon from "@mui/icons-material/StarBorder"
|
||||
import { InputLabel, TextField, Grid, FormControlLabel, Checkbox, Stack } from "@mui/material";
|
||||
import ToggleButton from "@mui/material/ToggleButton";
|
||||
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
|
||||
import MuiRating from "@mui/material/Rating";
|
||||
import { get } from "lodash-es";
|
||||
import Icon from "./Icon"
|
||||
|
||||
|
||||
export default function Settings({ onChange, config }: ISettingsProps) {
|
||||
return (
|
||||
@@ -22,6 +22,7 @@ export default function Settings({ onChange, config }: ISettingsProps) {
|
||||
if (input > 20) { input = 20 }
|
||||
onChange("max")(input);
|
||||
}}
|
||||
inputProps={{ min: 1, max: 20 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
@@ -79,9 +80,9 @@ export default function Settings({ onChange, config }: ISettingsProps) {
|
||||
<MuiRating aria-label="Preview of the rating field with custom icon"
|
||||
name="Preview"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
icon={get(config, "customIcons.rating") || <RatingIcon />}
|
||||
icon={<Icon config={config} isEmpty={false} />}
|
||||
size="small"
|
||||
emptyIcon={get(config, "customIcons.rating") || <RatingOutlineIcon />}
|
||||
emptyIcon={<Icon config={config} isEmpty={true} />}
|
||||
max={get(config, "max")}
|
||||
precision={get(config, "precision")}
|
||||
sx={{ pt: 0.5 }}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { ISideDrawerFieldProps } from "@src/components/fields/types";
|
||||
import { Grid } from "@mui/material";
|
||||
import { Rating as MuiRating } from "@mui/material";
|
||||
import "@mui/lab";
|
||||
import { getStateIcon, getStateOutline } from "./TableCell";
|
||||
import { fieldSx } from "@src/components/SideDrawer/utils";
|
||||
import Icon from "./Icon"
|
||||
|
||||
|
||||
export default function Rating({
|
||||
column,
|
||||
@@ -28,8 +29,8 @@ export default function Rating({
|
||||
onChange(newValue);
|
||||
onSubmit();
|
||||
}}
|
||||
icon={getStateIcon(column.config)}
|
||||
emptyIcon={getStateOutline(column.config)}
|
||||
icon={<Icon config={column.config} isEmpty={false}/>}
|
||||
emptyIcon={<Icon config={column.config} isEmpty={true} />}
|
||||
size="small"
|
||||
max={max}
|
||||
precision={precision}
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import { IHeavyCellProps } from "@src/components/fields/types";
|
||||
|
||||
import MuiRating from "@mui/material/Rating";
|
||||
import RatingIcon from "@mui/icons-material/Star";
|
||||
import RatingOutlineIcon from "@mui/icons-material/StarBorder"
|
||||
import { get } from "lodash-es";
|
||||
import Icon from "./Icon"
|
||||
|
||||
|
||||
export const getStateIcon = (config: any) => {
|
||||
// only use the config to get the custom rating icon if enabled via toggle
|
||||
if (!get(config, "customIcons.enabled")) { return <RatingIcon /> }
|
||||
return get(config, "customIcons.rating") || <RatingIcon />;
|
||||
};
|
||||
|
||||
export const getStateOutline = (config: any) => {
|
||||
if (!get(config, "customIcons.enabled")) { return <RatingOutlineIcon /> }
|
||||
return get(config, "customIcons.rating") || <RatingOutlineIcon />;
|
||||
}
|
||||
|
||||
export default function Rating({
|
||||
row,
|
||||
column,
|
||||
@@ -42,11 +29,11 @@ export default function Rating({
|
||||
name={`${row.id}-${column.key}`}
|
||||
value={typeof value === "number" ? value : 0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
icon={getStateIcon(column.config)}
|
||||
icon={<Icon config={column.config} isEmpty={false} />}
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
onChange={(_, newValue) => onSubmit(newValue)}
|
||||
emptyIcon={getStateOutline(column.config)}
|
||||
emptyIcon={<Icon config={column.config} isEmpty={true} />}
|
||||
max={max}
|
||||
precision={precision}
|
||||
sx={{ mx: -0.25 }}
|
||||
|
||||
@@ -20,8 +20,7 @@ export const useSubTableData = (
|
||||
location.pathname.split("/" + ROUTES.subTable)[0]
|
||||
);
|
||||
|
||||
// const [searchParams] = useSearchParams();
|
||||
// const parentLabels = searchParams.get("parentLabel");
|
||||
// Get params from URL: /table/:tableId/subTable/:docPath/:subTableKey
|
||||
let subTablePath = [
|
||||
rootTablePath,
|
||||
ROUTES.subTable,
|
||||
@@ -29,8 +28,6 @@ export const useSubTableData = (
|
||||
column.key,
|
||||
].join("/");
|
||||
|
||||
// if (parentLabels) subTablePath += `${parentLabels ?? ""},${label ?? ""}`;
|
||||
// else
|
||||
subTablePath += "?parentLabel=" + encodeURIComponent(label ?? "");
|
||||
|
||||
return { documentCount, label, subTablePath };
|
||||
|
||||
@@ -15,8 +15,8 @@ export const EXTERNAL_LINKS = {
|
||||
twitter: "https://twitter.com/rowyio",
|
||||
productHunt: "https://www.producthunt.com/products/rowy-2",
|
||||
|
||||
rowyRun: meta.repository.url.replace(".git", "Run"),
|
||||
rowyRunGitHub: meta.repository.url.replace(".git", "Run"),
|
||||
rowyRun: meta.repository.url.replace("rowy.git", "backend"),
|
||||
rowyRunGitHub: meta.repository.url.replace("rowy.git", "backend"),
|
||||
// prettier-ignore
|
||||
rowyRunDeploy: `https://deploy.cloud.run/?git_repo=${meta.repository.url.replace(".git", "Run")}.git`,
|
||||
|
||||
|
||||
@@ -36,14 +36,10 @@ export enum ROUTES {
|
||||
userSettings = "/settings/user",
|
||||
projectSettings = "/settings/project",
|
||||
members = "/members",
|
||||
debugSettings = "/settings/debug",
|
||||
debug = "/debug",
|
||||
|
||||
tutorial = "/tutorial",
|
||||
tableTutorial = "/tutorial/table",
|
||||
|
||||
test = "/test",
|
||||
themeTest = "/test/theme",
|
||||
rowyRunTest = "/test/rowyRunTest",
|
||||
}
|
||||
|
||||
export const ROUTE_TITLES = {
|
||||
@@ -66,7 +62,7 @@ export const ROUTE_TITLES = {
|
||||
[ROUTES.userSettings]: "Settings",
|
||||
[ROUTES.projectSettings]: "Project Settings",
|
||||
[ROUTES.members]: "Members",
|
||||
[ROUTES.debugSettings]: "Debug",
|
||||
[ROUTES.debug]: "Debug",
|
||||
|
||||
[ROUTES.tutorial]: "Tutorial",
|
||||
[ROUTES.tableTutorial]: {
|
||||
@@ -79,10 +75,6 @@ export const ROUTE_TITLES = {
|
||||
titleTransitionProps: { style: { transformOrigin: "0 50%" } },
|
||||
leftAligned: true,
|
||||
},
|
||||
|
||||
[ROUTES.test]: "Test",
|
||||
[ROUTES.themeTest]: "Theme Test",
|
||||
[ROUTES.rowyRunTest]: "Rowy Run Test",
|
||||
} as Record<
|
||||
ROUTES,
|
||||
| string
|
||||
|
||||
@@ -16,25 +16,50 @@ import { projectScope } from "@src/atoms/projectScope";
|
||||
import { firebaseStorageAtom } from "@src/sources/ProjectSourceFirebase";
|
||||
import { WIKI_LINKS } from "@src/constants/externalLinks";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import { generateId } from "@src/utils/table";
|
||||
|
||||
export type UploaderState = {
|
||||
export type UploadState = {
|
||||
progress: number;
|
||||
isLoading: boolean;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const initialState: UploaderState = { progress: 0, isLoading: false };
|
||||
export type UploaderState = {
|
||||
[fileName: string]: UploadState;
|
||||
};
|
||||
|
||||
const uploadReducer = (
|
||||
prevState: UploaderState,
|
||||
newProps: Partial<UploaderState>
|
||||
) => ({ ...prevState, ...newProps });
|
||||
action: {
|
||||
type: "reset" | "file_update";
|
||||
data?: { fileName: string; newProps: Partial<UploadState> };
|
||||
}
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "reset":
|
||||
return {};
|
||||
case "file_update":
|
||||
const { fileName, newProps } = action.data!;
|
||||
const nextState = { ...prevState };
|
||||
nextState[fileName] = {
|
||||
...nextState[fileName],
|
||||
...newProps,
|
||||
};
|
||||
return nextState;
|
||||
}
|
||||
};
|
||||
|
||||
export type UploadProps = {
|
||||
docRef: DocumentReference;
|
||||
fieldName: string;
|
||||
files: File[];
|
||||
previousValue?: FileValue[];
|
||||
onComplete?: (values: FileValue[]) => void;
|
||||
onComplete?: ({
|
||||
uploads,
|
||||
failures,
|
||||
}: {
|
||||
uploads: FileValue[];
|
||||
failures: string[];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
// TODO: GENERALIZE INTO ATOM
|
||||
@@ -42,125 +67,129 @@ const useFirebaseStorageUploader = () => {
|
||||
const [firebaseStorage] = useAtom(firebaseStorageAtom, projectScope);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [uploaderState, uploaderDispatch] = useReducer(uploadReducer, {
|
||||
...initialState,
|
||||
});
|
||||
const [uploaderState, uploaderDispatch] = useReducer(uploadReducer, {});
|
||||
|
||||
const upload = ({
|
||||
docRef,
|
||||
fieldName,
|
||||
files,
|
||||
previousValue,
|
||||
onComplete,
|
||||
}: UploadProps) => {
|
||||
uploaderDispatch({ isLoading: true });
|
||||
const upload = ({ docRef, fieldName, files }: UploadProps) => {
|
||||
const uploads = [] as FileValue[];
|
||||
const failures = [] as string[];
|
||||
const isCompleted = () => uploads.length + failures.length === files.length;
|
||||
|
||||
files.forEach((file) => {
|
||||
const storageRef = ref(
|
||||
firebaseStorage,
|
||||
`${docRef.path}/${fieldName}/${file.name}`
|
||||
);
|
||||
const uploadTask = uploadBytesResumable(storageRef, file);
|
||||
return new Promise((resolve) =>
|
||||
files.forEach((file) => {
|
||||
uploaderDispatch({
|
||||
type: "file_update",
|
||||
data: {
|
||||
fileName: file.name,
|
||||
newProps: { loading: true, progress: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
uploadTask.on(
|
||||
// event
|
||||
"state_changed",
|
||||
// observer
|
||||
(snapshot) => {
|
||||
// Get task progress, including the number of bytes uploaded and the total number of bytes to be uploaded
|
||||
const progress =
|
||||
(snapshot.bytesTransferred / snapshot.totalBytes) * 100;
|
||||
uploaderDispatch({ progress });
|
||||
console.log("Upload is " + progress + "% done");
|
||||
const storageRef = ref(
|
||||
firebaseStorage,
|
||||
`${docRef.path}/${fieldName}/${generateId()}-${file.name}`
|
||||
);
|
||||
const uploadTask = uploadBytesResumable(storageRef, file, {
|
||||
cacheControl: "public, max-age=31536000",
|
||||
});
|
||||
uploadTask.on(
|
||||
// event
|
||||
"state_changed",
|
||||
// observer
|
||||
(snapshot) => {
|
||||
// Get task progress, including the number of bytes uploaded and the total number of bytes to be uploaded
|
||||
const progress =
|
||||
(snapshot.bytesTransferred / snapshot.totalBytes) * 100;
|
||||
uploaderDispatch({
|
||||
type: "file_update",
|
||||
data: { fileName: file.name, newProps: { progress } },
|
||||
});
|
||||
},
|
||||
|
||||
switch (snapshot.state) {
|
||||
case "paused":
|
||||
console.log("Upload is paused");
|
||||
break;
|
||||
case "running":
|
||||
console.log("Upload is running");
|
||||
break;
|
||||
}
|
||||
},
|
||||
// error – must be any to access error.code
|
||||
(error: any) => {
|
||||
// A full list of error codes is available at
|
||||
// https://firebase.google.com/docs/storage/web/handle-errors
|
||||
const errorAction = {
|
||||
fileName: file.name,
|
||||
newProps: { loading: false } as Partial<UploadState>,
|
||||
};
|
||||
switch (error.code) {
|
||||
case "storage/unknown":
|
||||
// Unknown error occurred, inspect error.serverResponse
|
||||
enqueueSnackbar("Unknown error occurred", { variant: "error" });
|
||||
errorAction.newProps.error = error.serverResponse;
|
||||
break;
|
||||
|
||||
// error – must be any to access error.code
|
||||
(error: any) => {
|
||||
// A full list of error codes is available at
|
||||
// https://firebase.google.com/docs/storage/web/handle-errors
|
||||
switch (error.code) {
|
||||
case "storage/unknown":
|
||||
// Unknown error occurred, inspect error.serverResponse
|
||||
enqueueSnackbar("Unknown error occurred", { variant: "error" });
|
||||
uploaderDispatch({ error: error.serverResponse });
|
||||
break;
|
||||
case "storage/unauthorized":
|
||||
// User doesn't have permission to access the object
|
||||
enqueueSnackbar("You don’t have permissions to upload files", {
|
||||
variant: "error",
|
||||
action: (
|
||||
<Paper elevation={0} sx={{ borderRadius: 1 }}>
|
||||
<Button
|
||||
color="primary"
|
||||
href={
|
||||
WIKI_LINKS.setupRoles +
|
||||
"#write-firebase-storage-security-rules"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Fix
|
||||
<InlineOpenInNewIcon />
|
||||
</Button>
|
||||
</Paper>
|
||||
),
|
||||
});
|
||||
errorAction.newProps.error = error.code;
|
||||
break;
|
||||
|
||||
case "storage/unauthorized":
|
||||
// User doesn't have permission to access the object
|
||||
enqueueSnackbar("You don’t have permissions to upload files", {
|
||||
variant: "error",
|
||||
action: (
|
||||
<Paper elevation={0} sx={{ borderRadius: 1 }}>
|
||||
<Button
|
||||
color="primary"
|
||||
href={
|
||||
WIKI_LINKS.setupRoles +
|
||||
"#write-firebase-storage-security-rules"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Fix
|
||||
<InlineOpenInNewIcon />
|
||||
</Button>
|
||||
</Paper>
|
||||
),
|
||||
});
|
||||
uploaderDispatch({ error: error.code });
|
||||
break;
|
||||
|
||||
case "storage/canceled":
|
||||
// User canceled the upload
|
||||
uploaderDispatch({ error: error.code });
|
||||
break;
|
||||
|
||||
default:
|
||||
uploaderDispatch({ error: error.code });
|
||||
// Unknown error occurred, inspect error.serverResponse
|
||||
break;
|
||||
}
|
||||
|
||||
uploaderDispatch({ isLoading: false });
|
||||
},
|
||||
|
||||
// complete
|
||||
() => {
|
||||
uploaderDispatch({ isLoading: false });
|
||||
|
||||
// Upload completed successfully, now we can get the download URL
|
||||
getDownloadURL(uploadTask.snapshot.ref).then(
|
||||
(downloadURL: string) => {
|
||||
const newValue: FileValue[] = Array.isArray(previousValue)
|
||||
? [...previousValue]
|
||||
: [];
|
||||
|
||||
newValue.push({
|
||||
ref: uploadTask.snapshot.ref.fullPath,
|
||||
downloadURL,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
lastModifiedTS: file.lastModified,
|
||||
});
|
||||
// STore in the document if docRef provided
|
||||
// if (docRef && docRef.update)docRef.update({ [fieldName]: newValue });
|
||||
// Also call callback if it exists
|
||||
// IMPORTANT: SideDrawer form may not update its local values after this
|
||||
// function updates the doc, so you MUST update it manually
|
||||
// using this callback
|
||||
if (onComplete) onComplete(newValue);
|
||||
case "storage/canceled":
|
||||
default:
|
||||
errorAction.newProps.error = error.code;
|
||||
break;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
failures.push(file.name);
|
||||
uploaderDispatch({ type: "file_update", data: errorAction });
|
||||
if (isCompleted()) resolve(true);
|
||||
},
|
||||
|
||||
// complete
|
||||
() => {
|
||||
uploaderDispatch({
|
||||
type: "file_update",
|
||||
data: {
|
||||
fileName: file.name,
|
||||
newProps: { loading: false },
|
||||
},
|
||||
});
|
||||
|
||||
// Upload completed successfully, now we can get the download URL
|
||||
getDownloadURL(uploadTask.snapshot.ref).then(
|
||||
(downloadURL: string) => {
|
||||
// Store in the document if docRef provided
|
||||
// if (docRef && docRef.update)docRef.update({ [fieldName]: newValue });
|
||||
// Also call callback if it exists
|
||||
// IMPORTANT: SideDrawer form may not update its local values after this
|
||||
// function updates the doc, so you MUST update it manually
|
||||
// using this callback
|
||||
const obj = {
|
||||
ref: uploadTask.snapshot.ref.fullPath,
|
||||
downloadURL,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
lastModifiedTS: file.lastModified,
|
||||
};
|
||||
uploads.push(obj);
|
||||
if (isCompleted()) resolve(true);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
})
|
||||
).then(() => {
|
||||
uploaderDispatch({ type: "reset" });
|
||||
return { uploads, failures };
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
QueryConstraint,
|
||||
WhereFilterOp,
|
||||
documentId,
|
||||
getCountFromServer
|
||||
} from "firebase/firestore";
|
||||
import { useErrorHandler } from "react-error-boundary";
|
||||
|
||||
@@ -62,6 +63,8 @@ interface IUseFirestoreCollectionWithAtomOptions<T> {
|
||||
deleteDocAtom?: PrimitiveAtom<DeleteCollectionDocFunction | undefined>;
|
||||
/** Update this atom when we’re loading the next page, and if there is a next page available. Uses same scope as `dataScope`. */
|
||||
nextPageAtom?: PrimitiveAtom<NextPageState>;
|
||||
/** Set this atom's value to the number of docs in the collection on each new snapshot */
|
||||
serverDocCountAtom?: PrimitiveAtom<number> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +96,7 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
|
||||
updateDocAtom,
|
||||
deleteDocAtom,
|
||||
nextPageAtom,
|
||||
serverDocCountAtom
|
||||
} = options || {};
|
||||
|
||||
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
|
||||
@@ -116,9 +120,10 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
|
||||
void
|
||||
>(nextPageAtom || (dataAtom as any), dataScope);
|
||||
|
||||
const setServerDocCountAtom = useSetAtom(serverDocCountAtom || (dataAtom as any), dataScope)
|
||||
|
||||
// Store if we’re at the last page to prevent a new query from being created
|
||||
const [isLastPage, setIsLastPage] = useState(false);
|
||||
|
||||
// Create the query and memoize using Firestore’s queryEqual
|
||||
const memoizedQuery = useMemoValue(
|
||||
getQuery<T>(
|
||||
@@ -190,6 +195,12 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
|
||||
available: docs.length >= memoizedQuery.limit,
|
||||
}));
|
||||
}
|
||||
// on each new snapshot, use the query to get and set the document count from the server
|
||||
if (serverDocCountAtom) {
|
||||
getCountFromServer(memoizedQuery.unlimitedQuery).then((value) => {
|
||||
setServerDocCountAtom(value.data().count)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) onError(error as FirestoreError);
|
||||
else handleError(error);
|
||||
@@ -221,6 +232,7 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
|
||||
handleError,
|
||||
nextPageAtom,
|
||||
setNextPageAtom,
|
||||
setServerDocCountAtom
|
||||
]);
|
||||
|
||||
// Create variable for validity of query to pass to useEffect dependencies
|
||||
@@ -313,14 +325,13 @@ const getQuery = <T>(
|
||||
}
|
||||
|
||||
if (!collectionRef) return null;
|
||||
|
||||
const limit = (page + 1) * pageSize;
|
||||
const firestoreFilters = tableFiltersToFirestoreFilters(filters || []);
|
||||
|
||||
return {
|
||||
query: query<T>(
|
||||
collectionRef,
|
||||
queryLimit((page + 1) * pageSize),
|
||||
queryLimit(limit),
|
||||
...firestoreFilters,
|
||||
...(sorts?.map((order) => orderBy(order.key, order.direction)) || [])
|
||||
),
|
||||
@@ -328,6 +339,7 @@ const getQuery = <T>(
|
||||
limit,
|
||||
firestoreFilters,
|
||||
sorts,
|
||||
unlimitedQuery: query<T>(collectionRef, ...firestoreFilters)
|
||||
};
|
||||
} catch (e) {
|
||||
if (onError) onError(e as FirestoreError);
|
||||
@@ -376,12 +388,16 @@ export const tableFiltersToFirestoreFilters = (filters: TableFilter[]) => {
|
||||
} else if (filter.operator === "id-equal") {
|
||||
firestoreFilters.push(where(documentId(), "==", filter.value));
|
||||
continue;
|
||||
} else if (filter.operator === "color-equal") {
|
||||
firestoreFilters.push(where(filter.key.concat(".hex"), "==", filter.value.hex.toString()))
|
||||
continue
|
||||
} else if (filter.operator === "color-not-equal") {
|
||||
firestoreFilters.push(where(filter.key.concat(".hex"), "!=", filter.value.hex.toString()))
|
||||
continue
|
||||
}
|
||||
|
||||
firestoreFilters.push(
|
||||
where(filter.key, filter.operator as WhereFilterOp, filter.value)
|
||||
);
|
||||
}
|
||||
|
||||
return firestoreFilters;
|
||||
};
|
||||
|
||||
@@ -85,10 +85,17 @@ export function useFirestoreDocWithAtom<T = TableRow>(
|
||||
// Create a listener for the document
|
||||
const unsubscribe = onSnapshot(
|
||||
memoizedDocRef,
|
||||
{ includeMetadataChanges: true },
|
||||
(docSnapshot) => {
|
||||
try {
|
||||
// Create doc if it doesn’t exist
|
||||
if (!docSnapshot.exists() && !!createIfNonExistent) {
|
||||
// Create doc if it doesn’t exist and we’re online
|
||||
// WARNING: If offline and we doc doesn’t exist in cache, it will
|
||||
// ovewrite with default values when we go online
|
||||
if (
|
||||
!docSnapshot.exists() &&
|
||||
!!createIfNonExistent &&
|
||||
!docSnapshot.metadata.fromCache
|
||||
) {
|
||||
setDoc(docSnapshot.ref, createIfNonExistent);
|
||||
setDataAtom({ ...createIfNonExistent, _rowy_ref: docSnapshot.ref });
|
||||
} else {
|
||||
@@ -121,8 +128,6 @@ export function useFirestoreDocWithAtom<T = TableRow>(
|
||||
disableSuspense,
|
||||
createIfNonExistent,
|
||||
handleError,
|
||||
updateDataAtom,
|
||||
setUpdateDataAtom,
|
||||
]);
|
||||
|
||||
// Set updateDocAtom and deleteDocAtom values if they exist
|
||||
|
||||
22
src/hooks/useOffline.ts
Normal file
22
src/hooks/useOffline.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function useOffline() {
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Need to set here because the listener doesn’t fire on initial load
|
||||
setIsOffline(!window.navigator.onLine);
|
||||
|
||||
window.addEventListener("offline", handleOffline);
|
||||
window.addEventListener("online", handleOnline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
window.removeEventListener("online", handleOnline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOffline;
|
||||
}
|
||||
@@ -15,10 +15,15 @@ import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar";
|
||||
/**
|
||||
* Lock pages for admins only
|
||||
*/
|
||||
export default function AdminRoute({ children }: PropsWithChildren<{}>) {
|
||||
export default function AdminRoute({
|
||||
children,
|
||||
fallback,
|
||||
}: PropsWithChildren<{ fallback?: React.ReactNode }>) {
|
||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
||||
|
||||
if (!userRoles.includes("ADMIN"))
|
||||
if (!userRoles.includes("ADMIN")) {
|
||||
if (fallback) return fallback as JSX.Element;
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
role="alert"
|
||||
@@ -39,6 +44,7 @@ export default function AdminRoute({ children }: PropsWithChildren<{}>) {
|
||||
style={{ marginTop: -TOP_BAR_HEIGHT, marginBottom: -TOP_BAR_HEIGHT }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return children as JSX.Element;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function Navigation({ children }: React.PropsWithChildren<{}>) {
|
||||
<Loading fullScreen style={{ marginTop: -TOP_BAR_HEIGHT }} />
|
||||
}
|
||||
>
|
||||
<div style={{ flexGrow: 1, maxWidth: "100%" }}>
|
||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Outlet />
|
||||
{children}
|
||||
</div>
|
||||
|
||||
193
src/pages/Settings/DebugPage.tsx
Normal file
193
src/pages/Settings/DebugPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useSnackbar } from "notistack";
|
||||
import {
|
||||
updateDoc,
|
||||
doc,
|
||||
terminate,
|
||||
clearIndexedDbPersistence,
|
||||
} from "firebase/firestore";
|
||||
|
||||
import { Container, Stack, Button } from "@mui/material";
|
||||
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||
|
||||
import SettingsSection from "@src/components/Settings/SettingsSection";
|
||||
|
||||
import {
|
||||
projectScope,
|
||||
projectIdAtom,
|
||||
projectSettingsAtom,
|
||||
userRolesAtom,
|
||||
allUsersAtom,
|
||||
updateUserAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import UserManagementSourceFirebase from "@src/sources/MembersSourceFirebase";
|
||||
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
|
||||
import { CONFIG, TABLE_SCHEMAS, USERS } from "@src/config/dbPaths";
|
||||
import { getTableSchemaPath } from "@src/utils/table";
|
||||
import { useScrollToHash } from "@src/hooks/useScrollToHash";
|
||||
|
||||
export default function DebugPage() {
|
||||
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
|
||||
const [projectId] = useAtom(projectIdAtom, projectScope);
|
||||
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
|
||||
const [userRoles] = useAtom(userRolesAtom, projectScope);
|
||||
const [users] = useAtom(allUsersAtom, projectScope);
|
||||
const [updateUser] = useAtom(updateUserAtom, projectScope);
|
||||
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
|
||||
useScrollToHash();
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
|
||||
{userRoles.includes("ADMIN") && <UserManagementSourceFirebase />}
|
||||
|
||||
<Stack spacing={4}>
|
||||
<SettingsSection title="Firestore config" transitionTimeout={0 * 100}>
|
||||
<Button
|
||||
href={`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${CONFIG.replace(
|
||||
/\//g,
|
||||
"~2F"
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="primary"
|
||||
style={{ display: "flex", width: "max-content" }}
|
||||
>
|
||||
Config <InlineOpenInNewIcon />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
href={`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${TABLE_SCHEMAS.replace(
|
||||
/\//g,
|
||||
"~2F"
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="primary"
|
||||
style={{ display: "flex", width: "max-content" }}
|
||||
>
|
||||
Table schemas <InlineOpenInNewIcon />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
href={`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${USERS.replace(
|
||||
/\//g,
|
||||
"~2F"
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="primary"
|
||||
style={{ display: "flex", width: "max-content" }}
|
||||
>
|
||||
Users <InlineOpenInNewIcon />
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
{userRoles.includes("ADMIN") && (
|
||||
<SettingsSection
|
||||
title="Reset table filters"
|
||||
transitionTimeout={1 * 100}
|
||||
>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!updateUser)
|
||||
enqueueSnackbar("Could not update user settings", {
|
||||
variant: "error",
|
||||
});
|
||||
|
||||
const loadingSnackbar = enqueueSnackbar(
|
||||
"Resetting all user filters…",
|
||||
{
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
try {
|
||||
const promises = users.map((user) =>
|
||||
updateUser!(`${USERS}/${user._rowy_ref!.id}`, {
|
||||
tables: Object.entries(user.tables ?? {}).reduce(
|
||||
(a, [key, table]) => {
|
||||
a[key] = { ...table, filters: [] };
|
||||
return a;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
closeSnackbar(loadingSnackbar);
|
||||
enqueueSnackbar("Reset all user filters", {
|
||||
variant: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
enqueueSnackbar((e as Error).message, { variant: "error" });
|
||||
closeSnackbar(loadingSnackbar);
|
||||
}
|
||||
}}
|
||||
color="error"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
Reset all user filters
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!projectSettings.tables) {
|
||||
enqueueSnackbar("No tables to update");
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingSnackbar = enqueueSnackbar(
|
||||
"Resetting all table-level filters…",
|
||||
{ persist: true }
|
||||
);
|
||||
try {
|
||||
const promises = projectSettings.tables.map((table) =>
|
||||
updateDoc(
|
||||
doc(firebaseDb, getTableSchemaPath(table)),
|
||||
"filters",
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
closeSnackbar(loadingSnackbar);
|
||||
enqueueSnackbar("Reset all table-level filters", {
|
||||
variant: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
enqueueSnackbar((e as Error).message, { variant: "error" });
|
||||
closeSnackbar(loadingSnackbar);
|
||||
}
|
||||
}}
|
||||
color="error"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
Reset all table-level filters
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
title="Local Firestore instance"
|
||||
transitionTimeout={2 * 100}
|
||||
>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
enqueueSnackbar("Clearing cache. Page will reload…", {
|
||||
persist: true,
|
||||
});
|
||||
await terminate(firebaseDb);
|
||||
await clearIndexedDbPersistence(firebaseDb);
|
||||
window.location.reload();
|
||||
}}
|
||||
color="error"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
Reset local Firestore cache
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useSnackbar } from "notistack";
|
||||
import {
|
||||
updateDoc,
|
||||
doc,
|
||||
terminate,
|
||||
clearIndexedDbPersistence,
|
||||
} from "firebase/firestore";
|
||||
|
||||
import { Container, Stack, Button } from "@mui/material";
|
||||
|
||||
import SettingsSection from "@src/components/Settings/SettingsSection";
|
||||
|
||||
import {
|
||||
projectScope,
|
||||
projectSettingsAtom,
|
||||
allUsersAtom,
|
||||
updateUserAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import UserManagementSourceFirebase from "@src/sources/MembersSourceFirebase";
|
||||
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
|
||||
import { USERS } from "@src/config/dbPaths";
|
||||
import { getTableSchemaPath } from "@src/utils/table";
|
||||
import { useScrollToHash } from "@src/hooks/useScrollToHash";
|
||||
|
||||
export interface IProjectSettingsChildProps {
|
||||
settings: Record<string, any>;
|
||||
updateSettings: (data: Record<string, any>) => void;
|
||||
publicSettings: Record<string, any>;
|
||||
updatePublicSettings: (data: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export default function DebugSettingsPage() {
|
||||
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
|
||||
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
|
||||
const [users] = useAtom(allUsersAtom, projectScope);
|
||||
const [updateUser] = useAtom(updateUserAtom, projectScope);
|
||||
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
|
||||
useScrollToHash();
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
|
||||
<UserManagementSourceFirebase />
|
||||
|
||||
<Stack spacing={4}>
|
||||
<SettingsSection title="Reset table filters">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!updateUser)
|
||||
enqueueSnackbar("Could not update user settings", {
|
||||
variant: "error",
|
||||
});
|
||||
|
||||
const loadingSnackbar = enqueueSnackbar(
|
||||
"Resetting all user filters…",
|
||||
{
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
try {
|
||||
const promises = users.map((user) =>
|
||||
updateUser!(`${USERS}/${user._rowy_ref!.id}`, {
|
||||
tables: Object.entries(user.tables ?? {}).reduce(
|
||||
(a, [key, table]) => {
|
||||
a[key] = { ...table, filters: [] };
|
||||
return a;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
closeSnackbar(loadingSnackbar);
|
||||
enqueueSnackbar("Reset all user filters", {
|
||||
variant: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
enqueueSnackbar((e as Error).message, { variant: "error" });
|
||||
closeSnackbar(loadingSnackbar);
|
||||
}
|
||||
}}
|
||||
color="error"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
Reset all user filters
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!projectSettings.tables) {
|
||||
enqueueSnackbar("No tables to update");
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingSnackbar = enqueueSnackbar(
|
||||
"Resetting all table-level filters…",
|
||||
{ persist: true }
|
||||
);
|
||||
try {
|
||||
const promises = projectSettings.tables.map((table) =>
|
||||
updateDoc(
|
||||
doc(firebaseDb, getTableSchemaPath(table)),
|
||||
"filters",
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
closeSnackbar(loadingSnackbar);
|
||||
enqueueSnackbar("Reset all table-level filters", {
|
||||
variant: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
enqueueSnackbar((e as Error).message, { variant: "error" });
|
||||
closeSnackbar(loadingSnackbar);
|
||||
}
|
||||
}}
|
||||
color="error"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
Reset all table-level filters
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Local Firestore instance"
|
||||
transitionTimeout={1 * 100}
|
||||
>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
enqueueSnackbar("Clearing cache. Page will reload…", {
|
||||
persist: true,
|
||||
});
|
||||
await terminate(firebaseDb);
|
||||
await clearIndexedDbPersistence(firebaseDb);
|
||||
window.location.href = "/";
|
||||
}}
|
||||
color="error"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
Reset local Firestore cache
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,13 @@ import {
|
||||
updatePublicSettingsAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import { useScrollToHash } from "@src/hooks/useScrollToHash";
|
||||
import { ProjectSettings, PublicSettings } from "@src/types/settings";
|
||||
|
||||
export interface IProjectSettingsChildProps {
|
||||
settings: Record<string, any>;
|
||||
updateSettings: (data: Record<string, any>) => void;
|
||||
publicSettings: Record<string, any>;
|
||||
updatePublicSettings: (data: Record<string, any>) => void;
|
||||
settings: ProjectSettings;
|
||||
updateSettings: (data: Partial<ProjectSettings>) => void;
|
||||
publicSettings: PublicSettings;
|
||||
updatePublicSettings: (data: Partial<PublicSettings>) => void;
|
||||
}
|
||||
|
||||
export default function ProjectSettingsPage() {
|
||||
|
||||
@@ -18,10 +18,11 @@ import {
|
||||
updateUserSettingsAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import { useScrollToHash } from "@src/hooks/useScrollToHash";
|
||||
import { UserSettings } from "@src/types/settings";
|
||||
|
||||
export interface IUserSettingsChildProps {
|
||||
settings: Record<string, any>;
|
||||
updateSettings: (data: Record<string, any>) => void;
|
||||
settings: UserSettings;
|
||||
updateSettings: (data: Partial<UserSettings>) => void;
|
||||
}
|
||||
|
||||
export default function UserSettingsPage() {
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useAtom, Provider } from "jotai";
|
||||
import { DebugAtoms } from "@src/atoms/utils";
|
||||
import { useParams, useOutlet } from "react-router-dom";
|
||||
import { useParams, useOutlet, Link } from "react-router-dom";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { find, isEmpty } from "lodash-es";
|
||||
import useOffline from "@src/hooks/useOffline";
|
||||
|
||||
import ErrorFallback, {
|
||||
ERROR_TABLE_NOT_FOUND,
|
||||
} from "@src/components/ErrorFallback";
|
||||
import { Typography, Button } from "@mui/material";
|
||||
|
||||
import ErrorFallback from "@src/components/ErrorFallback";
|
||||
import TableSourceFirestore from "@src/sources/TableSourceFirestore";
|
||||
import TablePage from "./TablePage";
|
||||
import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton";
|
||||
import TableSkeleton from "@src/components/Table/TableSkeleton";
|
||||
import EmptyState from "@src/components/EmptyState";
|
||||
import OfflineIcon from "@mui/icons-material/CloudOff";
|
||||
import { Tables as TablesIcon } from "@src/assets/icons";
|
||||
|
||||
import {
|
||||
projectScope,
|
||||
projectIdAtom,
|
||||
currentUserAtom,
|
||||
projectSettingsAtom,
|
||||
tablesAtom,
|
||||
@@ -24,6 +29,9 @@ import {
|
||||
tableIdAtom,
|
||||
tableSettingsAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
import { SyncAtomValue } from "@src/atoms/utils";
|
||||
import { ROUTES } from "@src/constants/routes";
|
||||
import useDocumentTitle from "@src/hooks/useDocumentTitle";
|
||||
|
||||
/**
|
||||
* Wraps `TablePage` with the data for a top-level table.
|
||||
@@ -32,11 +40,15 @@ import {
|
||||
export default function ProvidedTablePage() {
|
||||
const { id } = useParams();
|
||||
const outlet = useOutlet();
|
||||
const [projectId] = useAtom(projectIdAtom, projectScope);
|
||||
const [currentUser] = useAtom(currentUserAtom, projectScope);
|
||||
const [projectSettings] = useAtom(projectSettingsAtom, projectScope);
|
||||
const [tables] = useAtom(tablesAtom, projectScope);
|
||||
const isOffline = useOffline();
|
||||
|
||||
const tableSettings = find(tables, ["id", id]);
|
||||
useDocumentTitle(projectId, tableSettings ? tableSettings.name : "Not found");
|
||||
|
||||
const tableSettings = useMemo(() => find(tables, ["id", id]), [tables, id]);
|
||||
if (!tableSettings) {
|
||||
if (isEmpty(projectSettings)) {
|
||||
return (
|
||||
@@ -46,7 +58,41 @@ export default function ProvidedTablePage() {
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
throw new Error(ERROR_TABLE_NOT_FOUND + ": " + id);
|
||||
if (isOffline) {
|
||||
return (
|
||||
<EmptyState
|
||||
role="alert"
|
||||
fullScreen
|
||||
Icon={OfflineIcon}
|
||||
message="You’re offline"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<EmptyState
|
||||
role="alert"
|
||||
fullScreen
|
||||
message="Table not found"
|
||||
description={
|
||||
<>
|
||||
<Typography variant="inherit">
|
||||
Make sure you have the right ID
|
||||
</Typography>
|
||||
<code>{id}</code>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
component={Link}
|
||||
to={ROUTES.tables}
|
||||
startIcon={<TablesIcon />}
|
||||
>
|
||||
All tables
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +116,12 @@ export default function ProvidedTablePage() {
|
||||
]}
|
||||
>
|
||||
<DebugAtoms scope={tableScope} />
|
||||
<SyncAtomValue
|
||||
atom={tableSettingsAtom}
|
||||
scope={tableScope}
|
||||
value={tableSettings}
|
||||
/>
|
||||
|
||||
<TableSourceFirestore />
|
||||
<Suspense
|
||||
fallback={
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Fade } from "@mui/material";
|
||||
import ErrorFallback, {
|
||||
InlineErrorFallback,
|
||||
} from "@src/components/ErrorFallback";
|
||||
import TableInformationDrawer from "@src/components/TableInformationDrawer/TableInformationDrawer";
|
||||
import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton";
|
||||
import TableSkeleton from "@src/components/Table/TableSkeleton";
|
||||
import EmptyTable from "@src/components/Table/EmptyTable";
|
||||
@@ -103,6 +104,12 @@ export default function TablePage({
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
|
||||
<Suspense fallback={null}>
|
||||
<TableInformationDrawer />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
{!disableModals && (
|
||||
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { find, groupBy, sortBy } from "lodash-es";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Container,
|
||||
@@ -13,12 +14,14 @@ import {
|
||||
IconButton,
|
||||
Zoom,
|
||||
} from "@mui/material";
|
||||
|
||||
import ViewListIcon from "@mui/icons-material/ViewListOutlined";
|
||||
import ViewGridIcon from "@mui/icons-material/ViewModuleOutlined";
|
||||
import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder";
|
||||
import FavoriteIcon from "@mui/icons-material/Favorite";
|
||||
import EditIcon from "@mui/icons-material/EditOutlined";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import InfoIcon from "@mui/icons-material/InfoOutlined";
|
||||
|
||||
import FloatingSearch from "@src/components/FloatingSearch";
|
||||
import SlideTransition from "@src/components/Modal/SlideTransition";
|
||||
@@ -54,7 +57,6 @@ export default function TablesPage() {
|
||||
tableSettingsDialogAtom,
|
||||
projectScope
|
||||
);
|
||||
|
||||
useScrollToHash();
|
||||
|
||||
const [results, query, handleQuery] = useBasicSearch(
|
||||
@@ -159,6 +161,15 @@ export default function TablesPage() {
|
||||
sx={view === "list" ? { p: 1.5 } : undefined}
|
||||
color="secondary"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Table information"
|
||||
size={view === "list" ? "large" : undefined}
|
||||
component={Link}
|
||||
to={`${getLink(table)}#sideDrawer="table-information"`}
|
||||
style={{ marginLeft: 0 }}
|
||||
>
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getTableSchemaAtom,
|
||||
AdditionalTableSettings,
|
||||
MinimumTableSettings,
|
||||
currentUserAtom,
|
||||
} from "@src/atoms/projectScope";
|
||||
import { firebaseDbAtom } from "./init";
|
||||
|
||||
@@ -21,12 +22,14 @@ import {
|
||||
TABLE_SCHEMAS,
|
||||
TABLE_GROUP_SCHEMAS,
|
||||
} from "@src/config/dbPaths";
|
||||
import { rowyUser } from "@src/utils/table";
|
||||
import { TableSettings, TableSchema } from "@src/types/table";
|
||||
import { FieldType } from "@src/constants/fields";
|
||||
import { getFieldProp } from "@src/components/fields";
|
||||
|
||||
export function useTableFunctions() {
|
||||
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
|
||||
const [currentUser] = useAtom(currentUserAtom, projectScope);
|
||||
|
||||
// Create a function to get the latest tables from project settings,
|
||||
// so we don’t create new functions when tables change
|
||||
@@ -93,10 +96,11 @@ export function useTableFunctions() {
|
||||
};
|
||||
}
|
||||
|
||||
const _createdBy = currentUser && rowyUser(currentUser);
|
||||
// Appends table to settings doc
|
||||
const promiseUpdateSettings = setDoc(
|
||||
doc(firebaseDb, SETTINGS),
|
||||
{ tables: [...tables, settings] },
|
||||
{ tables: [...tables, { ...settings, _createdBy }] },
|
||||
{ merge: true }
|
||||
);
|
||||
|
||||
@@ -120,7 +124,7 @@ export function useTableFunctions() {
|
||||
await Promise.all([promiseUpdateSettings, promiseAddSchema]);
|
||||
}
|
||||
);
|
||||
}, [firebaseDb, readTables, setCreateTable]);
|
||||
}, [currentUser, firebaseDb, readTables, setCreateTable]);
|
||||
|
||||
// Set the createTable function
|
||||
const setUpdateTable = useSetAtom(updateTableAtom, projectScope);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
_updateRowDbAtom,
|
||||
_deleteRowDbAtom,
|
||||
tableNextPageAtom,
|
||||
serverDocCountAtom
|
||||
} from "@src/atoms/tableScope";
|
||||
import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom";
|
||||
import useFirestoreCollectionWithAtom from "@src/hooks/useFirestoreCollectionWithAtom";
|
||||
@@ -32,7 +33,11 @@ import { getTableSchemaPath } from "@src/utils/table";
|
||||
export const TableSourceFirestore = memo(function TableSourceFirestore() {
|
||||
// Get tableSettings from tableId and tables in projectScope
|
||||
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
|
||||
const isCollectionGroup = tableSettings?.tableType === "collectionGroup";
|
||||
if (!tableSettings) throw new Error("No table config");
|
||||
if (!tableSettings.collection)
|
||||
throw new Error("Invalid table config: no collection");
|
||||
|
||||
const isCollectionGroup = tableSettings.tableType === "collectionGroup";
|
||||
|
||||
// Get tableSchema and store in tableSchemaAtom.
|
||||
// If it doesn’t exist, initialize columns
|
||||
@@ -63,7 +68,7 @@ export const TableSourceFirestore = memo(function TableSourceFirestore() {
|
||||
useFirestoreCollectionWithAtom(
|
||||
tableRowsDbAtom,
|
||||
tableScope,
|
||||
tableSettings?.collection,
|
||||
tableSettings.collection,
|
||||
{
|
||||
filters,
|
||||
sorts,
|
||||
@@ -73,6 +78,7 @@ export const TableSourceFirestore = memo(function TableSourceFirestore() {
|
||||
updateDocAtom: _updateRowDbAtom,
|
||||
deleteDocAtom: _deleteRowDbAtom,
|
||||
nextPageAtom: tableNextPageAtom,
|
||||
serverDocCountAtom: serverDocCountAtom
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {} from "@mui/lab/themeAugmentation";
|
||||
import { MultiSelectProps } from "@rowy/multiselect";
|
||||
import { toRem } from "./typography";
|
||||
|
||||
import ModalTransition from "@src/components/Modal/ModalTransition";
|
||||
import RadioIcon from "@src/theme/RadioIcon";
|
||||
import CheckboxIcon from "@src/theme/CheckboxIcon";
|
||||
import CheckboxIndeterminateIcon from "@src/theme/CheckboxIndeterminateIcon";
|
||||
@@ -248,6 +249,9 @@ export const components = (theme: Theme): ThemeOptions => {
|
||||
},
|
||||
|
||||
MuiDialog: {
|
||||
defaultProps: {
|
||||
TransitionComponent: ModalTransition,
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
"--dialog-title-height": "64px",
|
||||
@@ -307,14 +311,8 @@ export const components = (theme: Theme): ThemeOptions => {
|
||||
MuiDialogTitle: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: "var(--dialog-spacing)",
|
||||
paddingTop: (64 - 28) / 2,
|
||||
paddingBottom: (64 - 28) / 2,
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
paddingTop: (56 - 28) / 2,
|
||||
paddingBottom: (56 - 28) / 2,
|
||||
},
|
||||
...(theme.typography.h5 as any),
|
||||
padding: `calc((var(--dialog-title-height) - ${theme.typography.h5.lineHeight} * ${theme.typography.h5.fontSize}) / 2) var(--dialog-spacing)`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -568,6 +566,13 @@ export const components = (theme: Theme): ThemeOptions => {
|
||||
|
||||
MuiMenu: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
".MuiDialog-root + & .MuiMenu-paper, form:has(.MuiDialog-root) + & .MuiMenu-paper, .MuiDialog-root & .MuiMenu-paper":
|
||||
{
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.2))", // elevation 50
|
||||
},
|
||||
},
|
||||
list: { padding: theme.spacing(0.5, 0) },
|
||||
},
|
||||
},
|
||||
@@ -1370,11 +1375,12 @@ export const components = (theme: Theme): ThemeOptions => {
|
||||
|
||||
MuiRating: {
|
||||
styleOverrides: {
|
||||
iconFilled: { color: theme.palette.text.secondary },
|
||||
icon: {
|
||||
// https://github.com/mui/material-ui/issues/32557
|
||||
"& .MuiSvgIcon-root": { pointerEvents: "auto" },
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
iconEmpty: { opacity: 0.38 },
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
64
src/types/settings.d.ts
vendored
Normal file
64
src/types/settings.d.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ThemeOptions } from "@mui/material";
|
||||
import { TableSettings, TableFilter, TableRowRef, TableSort } from "./table";
|
||||
|
||||
/** Public settings are visible to unauthenticated users */
|
||||
export type PublicSettings = Partial<{
|
||||
signInOptions: Array<
|
||||
| "google"
|
||||
| "twitter"
|
||||
| "facebook"
|
||||
| "github"
|
||||
| "microsoft"
|
||||
| "apple"
|
||||
| "yahoo"
|
||||
| "email"
|
||||
| "phone"
|
||||
| "anonymous"
|
||||
>;
|
||||
theme: Record<"base" | "light" | "dark", ThemeOptions>;
|
||||
}>;
|
||||
|
||||
/** Project settings are visible to authenticated users */
|
||||
export type ProjectSettings = Partial<{
|
||||
tables: TableSettings[];
|
||||
|
||||
setupCompleted: boolean;
|
||||
|
||||
rowyRunUrl: string;
|
||||
rowyRunRegion: string;
|
||||
rowyRunDeployStatus: "BUILDING" | "COMPLETE";
|
||||
services: Partial<{
|
||||
hooks: string;
|
||||
builder: string;
|
||||
terminal: string;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
/** User info and settings */
|
||||
export type UserSettings = Partial<{
|
||||
_rowy_ref: TableRowRef;
|
||||
/** Synced from user auth info */
|
||||
user: {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
photoURL?: string;
|
||||
phoneNumber?: string;
|
||||
};
|
||||
roles: string[];
|
||||
|
||||
theme: Record<"base" | "light" | "dark", ThemeOptions>;
|
||||
|
||||
favoriteTables: string[];
|
||||
/** Stores user overrides */
|
||||
tables: Record<
|
||||
string,
|
||||
Partial<{
|
||||
filters: TableFilter[];
|
||||
hiddenFields: string[];
|
||||
sorts: TableSort[];
|
||||
}>
|
||||
>;
|
||||
|
||||
/** Stores table tutorial completion */
|
||||
tableTutorialComplete?: boolean;
|
||||
}>;
|
||||
26
src/types/table.d.ts
vendored
26
src/types/table.d.ts
vendored
@@ -4,7 +4,10 @@ import type {
|
||||
DocumentData,
|
||||
DocumentReference,
|
||||
} from "firebase/firestore";
|
||||
import { IExtension } from "@src/components/TableModals/ExtensionsModal/utils";
|
||||
import {
|
||||
IExtension,
|
||||
IRuntimeOptions,
|
||||
} from "@src/components/TableModals/ExtensionsModal/utils";
|
||||
import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils";
|
||||
|
||||
/**
|
||||
@@ -70,6 +73,18 @@ export type TableSettings = {
|
||||
|
||||
section: string;
|
||||
description?: string;
|
||||
details?: string;
|
||||
thumbnailURL?: string;
|
||||
|
||||
_createdBy?: {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
emailVerified: boolean;
|
||||
isAnonymous: boolean;
|
||||
photoURL?: string;
|
||||
uid: string;
|
||||
timestamp: firebase.firestore.Timestamp;
|
||||
};
|
||||
|
||||
tableType: "primaryCollection" | "collectionGroup";
|
||||
|
||||
@@ -92,6 +107,7 @@ export type TableSchema = {
|
||||
extensionObjects?: IExtension[];
|
||||
compiledExtension?: string;
|
||||
webhooks?: IWebhook[];
|
||||
runtimeOptions?: IRuntimeOptions;
|
||||
|
||||
/** @deprecated Migrate to Extensions */
|
||||
sparks?: string;
|
||||
@@ -133,6 +149,10 @@ export type ColumnConfig = {
|
||||
};
|
||||
/** FieldType to render for Derivative fields */
|
||||
renderFieldType?: FieldType;
|
||||
/** Used in Derivative fields */
|
||||
listenerFields?: string[];
|
||||
/** Used in Derivative and Action fields */
|
||||
requiredFields?: string[];
|
||||
/** For sub-table fields */
|
||||
parentLabel?: string[];
|
||||
|
||||
@@ -153,7 +173,9 @@ export type TableFilter = {
|
||||
| "date-before-equal"
|
||||
| "date-after-equal"
|
||||
| "time-minute-equal"
|
||||
| "id-equal";
|
||||
| "id-equal"
|
||||
| "color-equal"
|
||||
| "color-not-equal";
|
||||
value: any;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user