add tables page, CodeEditor, TableSettingsDialog

This commit is contained in:
Sidney Alcantara
2022-05-03 01:20:59 +10:00
parent 58b2c06d7d
commit 8fed928c5d
44 changed files with 9802 additions and 68 deletions

View File

@@ -11,9 +11,12 @@
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mdi/js": "^6.6.96",
"@monaco-editor/react": "^4.4.4",
"@mui/icons-material": "^5.6.0",
"@mui/lab": "^5.0.0-alpha.76",
"@mui/material": "^5.6.0",
"@mui/styles": "^5.6.2",
"@rowy/form-builder": "^0.5.5",
"@rowy/multiselect": "^0.3.0",
"compare-versions": "^4.1.3",
"date-fns": "^2.28.0",
@@ -21,9 +24,11 @@
"firebase": "^9.6.11",
"firebaseui": "^6.0.1",
"jotai": "^1.6.5",
"json-stable-stringify-without-jsonify": "^1.0.1",
"lodash-es": "^4.17.21",
"match-sorter": "^6.3.1",
"notistack": "^2.0.4",
"quicktype-core": "^6.0.71",
"react": "^18.0.0",
"react-color-palette": "^6.2.0",
"react-data-grid": "7.0.0-beta.5",
@@ -32,9 +37,13 @@
"react-element-scroll-hook": "^1.1.0",
"react-error-boundary": "^3.1.4",
"react-helmet-async": "^1.3.0",
"react-hook-form": "^7.30.0",
"react-markdown": "^8.0.3",
"react-router-dom": "^6.3.0",
"react-router-hash-link": "^2.4.3",
"react-scripts": "^5.0.0",
"remark-gfm": "^3.0.1",
"swr": "^1.3.0",
"tss-react": "^3.6.2",
"typescript": "^4.6.3",
"use-debounce": "^7.0.1",
@@ -132,7 +141,9 @@
"eslint-plugin-tsdoc": "^0.2.16",
"husky": ">=7.0.4",
"lint-staged": ">=12.3.7",
"monaco-editor": "^0.33.0",
"prettier": "^2.6.2",
"raw-loader": "^4.0.2",
"source-map-explorer": "^2.5.2"
},
"resolutions": {

View File

@@ -9,6 +9,7 @@ import RowyRunModal from "@src/components/RowyRunModal";
import NotFound from "@src/pages/NotFound";
import RequireAuth from "@src/layouts/RequireAuth";
import Navigation from "@src/layouts/Navigation";
import TableSettingsDialog from "@src/components/TableSettingsDialog";
import { globalScope, currentUserAtom } from "@src/atoms/globalScope";
import { ROUTES } from "@src/constants/routes";
@@ -28,13 +29,15 @@ const ImpersonatorAuthPage = lazy(() => import("@src/pages/Auth/ImpersonatorAuth
// prettier-ignore
const SetupPage = lazy(() => import("@src/pages/Setup" /* webpackChunkName: "SetupPage" */));
// prettier-ignore
const TablesPage = lazy(() => import("@src/pages/Tables" /* webpackChunkName: "TablesPage" */));
// prettier-ignore
const UserSettingsPage = lazy(() => import("@src/pages/Settings/UserSettings" /* webpackChunkName: "UserSettingsPage" */));
// prettier-ignore
const ProjectSettingsPage = lazy(() => import("@src/pages/Settings/ProjectSettings" /* webpackChunkName: "ProjectSettingsPage" */));
// prettier-ignore
const UserManagementPage = lazy(() => import("@src/pages/Settings/UserManagement" /* webpackChunkName: "UserManagementPage" */));
// prettier-ignore
// const RowyRunTestPage = lazy(() => import("@src/pages/RowyRunTest" /* webpackChunkName: "RowyRunTestPage" */));
export default function App() {
@@ -44,7 +47,7 @@ export default function App() {
<Suspense fallback={<Loading fullScreen />}>
<ProjectSourceFirebase />
<ConfirmDialog />
<RowyRunModal/>
<RowyRunModal />
{currentUser === undefined ? (
<Loading fullScreen message="Authenticating" />
@@ -71,21 +74,34 @@ export default function App() {
path="/"
element={
<RequireAuth>
<Navigation />
<Navigation>
<TableSettingsDialog />
</Navigation>
</RequireAuth>
}
>
<Route
path={ROUTES.home}
element={<Navigate to={ROUTES.tables} replace />}
/>
<Route path={ROUTES.tables} element={<TablesPage />} />
<Route
path={ROUTES.settings}
element={<Navigate to={ROUTES.userSettings} replace />}
/>
<Route path={ROUTES.userSettings} element={<UserSettingsPage />} />
<Route path={ROUTES.projectSettings} element={<ProjectSettingsPage />} />
<Route path={ROUTES.userManagement} element={<UserManagementPage />} />
<Route
path={ROUTES.projectSettings}
element={<ProjectSettingsPage />}
/>
<Route
path={ROUTES.userManagement}
element={<UserManagementPage />}
/>
{/* <Route path={ROUTES.rowyRunTest} element={<RowyRunTestPage />} /> */}
<Route path="/jotaiTest" element={<JotaiTestPage />} />
<Route path="/jotaiTest" element={<JotaiTestPage />} />
</Route>
{/* <Route path="/jotaiTest" element={<JotaiTestPage />} /> */}

View File

@@ -0,0 +1,120 @@
import { useState } from "react";
import Editor, { EditorProps } from "@monaco-editor/react";
import type { editor } from "monaco-editor/esm/vs/editor/editor.api";
import { useTheme, Box, BoxProps } from "@mui/material";
import TrapFocus from "@mui/material/Unstable_TrapFocus";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import ResizeBottomRightIcon from "@src/assets/icons/ResizeBottomRight";
import useMonacoCustomizations, {
IUseMonacoCustomizationsProps,
} from "./useMonacoCustomizations";
import FullScreenButton from "./FullScreenButton";
export interface ICodeEditorProps
extends Partial<EditorProps>,
Omit<IUseMonacoCustomizationsProps, "fullScreen"> {
value: string;
containerProps?: Partial<BoxProps>;
onValidate?: EditorProps["onValidate"];
onValidStatusUpdate?: (result: {
isValid: boolean;
markers: editor.IMarker[];
}) => void;
}
export default function CodeEditor({
value,
minHeight = 100,
disabled,
error,
containerProps,
onValidate,
onValidStatusUpdate,
extraLibs,
diagnosticsOptions,
onUnmount,
defaultLanguage = "javascript",
...props
}: ICodeEditorProps) {
const theme = useTheme();
// Store editor value to prevent code editor values not being saved when
// Side Drawer is in the middle of a refresh
const [initialEditorValue] = useState(value ?? "");
const [fullScreen, setFullScreen] = useState(false);
const { boxSx } = useMonacoCustomizations({
minHeight,
disabled,
error,
extraLibs,
diagnosticsOptions,
onUnmount,
fullScreen,
});
const onValidate_: EditorProps["onValidate"] = (markers) => {
onValidStatusUpdate?.({ isValid: markers.length <= 0, markers });
onValidate?.(markers);
};
return (
<TrapFocus open={fullScreen}>
<Box
sx={[
boxSx,
...(Array.isArray(containerProps?.sx)
? containerProps!.sx
: containerProps?.sx
? [containerProps.sx]
: []),
]}
style={fullScreen ? { height: "100%" } : {}}
>
<Editor
defaultLanguage={defaultLanguage}
value={initialEditorValue}
loading={<CircularProgressOptical size={20} sx={{ m: 2 }} />}
className="editor"
{...props}
onValidate={onValidate_}
options={{
readOnly: disabled,
fontFamily: theme.typography.fontFamilyMono,
rulers: [80],
minimap: { enabled: false },
lineNumbersMinChars: 4,
lineDecorationsWidth: 0,
automaticLayout: true,
fixedOverflowWidgets: true,
tabSize: 2,
...props.options,
}}
/>
<FullScreenButton
onClick={() => setFullScreen((f) => !f)}
active={fullScreen}
/>
{!fullScreen && (
<ResizeBottomRightIcon
aria-label="Resize code editor"
color="action"
sx={{
position: "absolute",
bottom: 1,
right: 1,
zIndex: 1,
}}
/>
)}
</Box>
</TrapFocus>
);
}

View File

@@ -0,0 +1,122 @@
import { useAtom } from "jotai";
import { Stack, Typography, Grid, Tooltip, IconButton } from "@mui/material";
import SecretsIcon from "@mui/icons-material/VpnKeyOutlined";
import FunctionsIcon from "@mui/icons-material/CloudOutlined";
import DocsIcon from "@mui/icons-material/DescriptionOutlined";
import { globalScope, projectIdAtom } from "@src/atoms/globalScope";
export interface ICodeEditorHelperProps {
docLink: string;
additionalVariables?: {
key: string;
description: string;
}[];
}
export default function CodeEditorHelper({
docLink,
additionalVariables,
}: ICodeEditorHelperProps) {
const [projectId] = useAtom(projectIdAtom, globalScope);
const availableVariables = [
{
key: "row",
description: `row has the value of doc.data() it has type definitions using this table's schema, but you can access any field in the document.`,
},
{
key: "db",
description: `db object provides access to firestore database instance of this project. giving you access to any collection or document in this firestore instance`,
},
{
key: "ref",
description: `ref object that represents the reference to the current row in firestore db (ie: doc.ref).`,
},
{
key: "auth",
description: `auth provides access to a firebase auth instance, can be used to manage auth users or generate tokens.`,
},
{
key: "storage",
description: `firebase Storage can be accessed through this, storage.bucket() returns default storage bucket of the firebase project.`,
},
{
key: "rowy",
description: `rowy provides a set of functions that are commonly used, such as easy access to GCP Secret Manager`,
},
];
return (
<Stack
direction="row"
alignItems="flex-start"
justifyItems="space-between"
spacing={1}
justifyContent="space-between"
sx={{ my: 1 }}
>
<Typography variant="body2" color="textSecondary">
Available:
</Typography>
<Grid
container
spacing={1}
style={{ flexGrow: 1, marginTop: -8, marginLeft: 0 }}
>
{availableVariables.concat(additionalVariables ?? []).map((v) => (
<Grid item key={v.key}>
<Tooltip title={v.description}>
<code>{v.key}</code>
</Tooltip>
</Grid>
))}
</Grid>
<Stack
direction="row"
alignItems="center"
spacing={1}
style={{ marginTop: -4 }}
>
<Tooltip title="Secret Manager&nbsp;↗">
<IconButton
size="small"
color="primary"
target="_blank"
rel="noopener noreferrer"
href={`https://console.cloud.google.com/security/secret-manager?project=${projectId}`}
>
<SecretsIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Configure Cloud Function&nbsp;↗">
<IconButton
size="small"
color="primary"
target="_blank"
rel="noopener noreferrer"
href={`https://console.cloud.google.com/functions/list?project=${projectId}`}
>
<FunctionsIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Examples & documentation&nbsp;↗">
<IconButton
size="small"
color="primary"
target="_blank"
rel="noopener noreferrer"
href={docLink}
>
<DocsIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,117 @@
import { useState } from "react";
import {
DiffEditor as MonacoDiffEditor,
DiffEditorProps,
EditorProps,
} from "@monaco-editor/react";
import { useTheme, Box, BoxProps } from "@mui/material";
import TrapFocus from "@mui/material/Unstable_TrapFocus";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import ResizeBottomRightIcon from "@src/assets/icons/ResizeBottomRight";
import useMonacoCustomizations, {
IUseMonacoCustomizationsProps,
} from "./useMonacoCustomizations";
import FullScreenButton from "./FullScreenButton";
export interface IDiffEditorProps
extends Partial<DiffEditorProps>,
Omit<IUseMonacoCustomizationsProps, "fullScreen"> {
onChange?: EditorProps["onChange"];
containerProps?: Partial<BoxProps>;
}
export default function DiffEditor({
onChange,
minHeight = 100,
disabled,
error,
containerProps,
extraLibs,
diagnosticsOptions,
onUnmount,
...props
}: IDiffEditorProps) {
const theme = useTheme();
const [fullScreen, setFullScreen] = useState(false);
const { boxSx } = useMonacoCustomizations({
minHeight,
disabled,
error,
extraLibs,
diagnosticsOptions,
onUnmount,
fullScreen,
});
// Needs manual patch since `onMount` prop is not available in `DiffEditor`
// https://github.com/suren-atoyan/monaco-react/issues/281
const handleEditorMount: DiffEditorProps["onMount"] = (editor, monaco) => {
const modifiedEditor = editor.getModifiedEditor();
modifiedEditor.onDidChangeModelContent((ev) => {
onChange?.(modifiedEditor.getValue(), ev);
});
props.onMount?.(editor, monaco);
};
return (
<TrapFocus open={fullScreen}>
<Box
sx={[
boxSx,
...(Array.isArray(containerProps?.sx)
? containerProps!.sx
: containerProps?.sx
? [containerProps.sx]
: []),
]}
style={fullScreen ? { height: "100%" } : {}}
>
<MonacoDiffEditor
language="javascript"
loading={<CircularProgressOptical size={20} sx={{ m: 2 }} />}
className="editor"
{...props}
onMount={handleEditorMount}
options={
{
readOnly: disabled,
fontFamily: theme.typography.fontFamilyMono,
rulers: [80],
minimap: { enabled: false },
lineNumbersMinChars: 4,
lineDecorationsWidth: "18",
automaticLayout: true,
fixedOverflowWidgets: true,
tabSize: 2,
...props.options,
} as any
}
/>
<FullScreenButton
onClick={() => setFullScreen((f) => !f)}
active={fullScreen}
style={{ right: 32 }}
/>
<ResizeBottomRightIcon
aria-label="Resize code editor"
color="action"
sx={{
position: "absolute",
bottom: 1,
right: 1,
zIndex: 1,
}}
/>
</Box>
</TrapFocus>
);
}

View File

@@ -0,0 +1,32 @@
import { Button, ButtonProps } from "@mui/material";
import FullscreenIcon from "@mui/icons-material/Fullscreen";
import FullscreenExitIcon from "@mui/icons-material/FullscreenExit";
export interface IFullScreenButtonProps extends ButtonProps {
active: boolean;
}
export default function FullScreenButton({
active,
...props
}: IFullScreenButtonProps) {
return (
<Button
aria-label={`${active ? "Exit" : "Enter"} full screen`}
variant={active ? "contained" : "outlined"}
color={active ? "secondary" : undefined}
{...props}
style={{
position: "absolute",
bottom: 4,
right: 16,
zIndex: 2,
minWidth: 32,
padding: 0,
...props.style,
}}
>
{active ? <FullscreenExitIcon /> : <FullscreenIcon />}
</Button>
);
}

View File

@@ -0,0 +1,98 @@
/* eslint-disable tsdoc/syntax */
type Trigger = "create" | "update" | "delete";
type Triggers = Trigger[];
// function types that defines extension body and should run
type Condition =
| boolean
| ((data: ExtensionContext) => boolean | Promise<boolean>);
// the argument that the extension body takes in
type ExtensionContext = {
row: Row;
ref: FirebaseFirestore.DocumentReference;
storage: firebasestorage.Storage;
db: FirebaseFirestore.Firestore;
auth: firebaseauth.BaseAuth;
change: any;
triggerType: Triggers;
fieldTypes: any;
extensionConfig: {
label: string;
type: string;
triggers: Trigger[];
conditions: Condition;
requiredFields: string[];
extensionBody: any;
};
RULES_UTILS: any;
};
// extension body definition
type slackEmailBody = {
channels?: string[];
text?: string;
emails: string[];
blocks?: object[];
attachments?: any;
};
type slackChannelBody = {
channels: string[];
text?: string;
emails?: string[];
blocks?: object[];
attachments?: any;
};
type DocSyncBody = (context: ExtensionContext) => Promise<{
fieldsToSync: Fields;
row: Row;
targetPath: string;
}>;
type HistorySnapshotBody = (context: ExtensionContext) => Promise<{
trackedFields: Fields;
}>;
type AlgoliaIndexBody = (context: ExtensionContext) => Promise<{
fieldsToSync: Fields;
index: string;
row: Row;
objectID: string;
}>;
type MeiliIndexBody = (context: ExtensionContext) => Promise<{
fieldsToSync: Fields;
index: string;
row: Row;
objectID: string;
}>;
type BigqueryIndexBody = (context: ExtensionContext) => Promise<{
fieldsToSync: Fields;
index: string;
row: Row;
objectID: string;
}>;
type SlackMessageBody = (
context: ExtensionContext
) => Promise<slackEmailBody | slackChannelBody>;
type SendgridEmailBody = (context: ExtensionContext) => Promise<any>;
type ApiCallBody = (context: ExtensionContext) => Promise<{
body: string;
url: string;
method: string;
callback: any;
}>;
type TwilioMessageBody = (context: ExtensionContext) => Promise<{
body: string;
from: string;
to: string;
}>;
type TaskBody = (context: ExtensionContext) => Promise<any>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,447 @@
/* eslint-disable tsdoc/syntax */
/* eslint-disable @typescript-eslint/ban-types */
// node_modules/@google-cloud/storage/build/src/bucket.d.ts
declare class Bucket {
/**
* The bucket's name.
* @name Bucket#name
* @type {string}
*/
name: string;
/**
* A reference to the {@link Storage} associated with this {@link Bucket}
* instance.
* @name Bucket#storage
* @type {Storage}
*/
storage: Storage;
/**
* A user project to apply to each request from this bucket.
* @name Bucket#userProject
* @type {string}
*/
userProject?: string;
/**
* Cloud Storage uses access control lists (ACLs) to manage object and
* bucket access. ACLs are the mechanism you use to share objects with other
* users and allow other users to access your buckets and objects.
*
* An ACL consists of one or more entries, where each entry grants permissions
* to an entity. Permissions define the actions that can be performed against
* an object or bucket (for example, `READ` or `WRITE`); the entity defines
* who the permission applies to (for example, a specific user or group of
* users).
*
* The `acl` object on a Bucket instance provides methods to get you a list of
* the ACLs defined on your bucket, as well as set, update, and delete them.
*
* Buckets also have
* [default
* ACLs](https://cloud.google.com/storage/docs/access-control/lists#default)
* for all created files. Default ACLs specify permissions that all new
* objects added to the bucket will inherit by default. You can add, delete,
* get, and update entities and permissions for these as well with
* {@link Bucket#acl.default}.
*
* @see [About Access Control Lists]{@link http://goo.gl/6qBBPO}
* @see [Default ACLs]{@link https://cloud.google.com/storage/docs/access-control/lists#default}
*
* @name Bucket#acl
* @mixes Acl
* @property {Acl} default Cloud Storage Buckets have
* [default
* ACLs](https://cloud.google.com/storage/docs/access-control/lists#default)
* for all created files. You can add, delete, get, and update entities and
* permissions for these as well. The method signatures and examples are all
* the same, after only prefixing the method call with `default`.
*
* @example
* const {Storage} = require('@google-cloud/storage');
* const storage = new Storage();
*
* //-
* // Make a bucket's contents publicly readable.
* //-
* const myBucket = storage.bucket('my-bucket');
*
* const options = {
* entity: 'allUsers',
* role: storage.acl.READER_ROLE
* };
*
* myBucket.acl.add(options, function(err, aclObject) {});
*
* //-
* // If the callback is omitted, we'll return a Promise.
* //-
* myBucket.acl.add(options).then(function(data) {
* const aclObject = data[0];
* const apiResponse = data[1];
* });
*
* @example <caption>include:samples/acl.js</caption>
* region_tag:storage_print_bucket_acl
* Example of printing a bucket's ACL:
*
* @example <caption>include:samples/acl.js</caption>
* region_tag:storage_print_bucket_acl_for_user
* Example of printing a bucket's ACL for a specific user:
*
* @example <caption>include:samples/acl.js</caption>
* region_tag:storage_add_bucket_owner
* Example of adding an owner to a bucket:
*
* @example <caption>include:samples/acl.js</caption>
* region_tag:storage_remove_bucket_owner
* Example of removing an owner from a bucket:
*
* @example <caption>include:samples/acl.js</caption>
* region_tag:storage_add_bucket_default_owner
* Example of adding a default owner to a bucket:
*
* @example <caption>include:samples/acl.js</caption>
* region_tag:storage_remove_bucket_default_owner
* Example of removing a default owner from a bucket:
*/
acl: Acl;
/**
* Get and set IAM policies for your bucket.
*
* @name Bucket#iam
* @mixes Iam
*
* @see [Cloud Storage IAM Management](https://cloud.google.com/storage/docs/access-control/iam#short_title_iam_management)
* @see [Granting, Changing, and Revoking Access](https://cloud.google.com/iam/docs/granting-changing-revoking-access)
* @see [IAM Roles](https://cloud.google.com/iam/docs/understanding-roles)
*
* @example
* const {Storage} = require('@google-cloud/storage');
* const storage = new Storage();
* const bucket = storage.bucket('albums');
*
* //-
* // Get the IAM policy for your bucket.
* //-
* bucket.iam.getPolicy(function(err, policy) {
* console.log(policy);
* });
*
* //-
* // If the callback is omitted, we'll return a Promise.
* //-
* bucket.iam.getPolicy().then(function(data) {
* const policy = data[0];
* const apiResponse = data[1];
* });
*
* @example <caption>include:samples/iam.js</caption>
* region_tag:storage_view_bucket_iam_members
* Example of retrieving a bucket's IAM policy:
*
* @example <caption>include:samples/iam.js</caption>
* region_tag:storage_add_bucket_iam_member
* Example of adding to a bucket's IAM policy:
*
* @example <caption>include:samples/iam.js</caption>
* region_tag:storage_remove_bucket_iam_member
* Example of removing from a bucket's IAM policy:
*/
iam: Iam;
/**
* Get {@link File} objects for the files currently in the bucket as a
* readable object stream.
*
* @method Bucket#getFilesStream
* @param {GetFilesOptions} [query] Query object for listing files.
* @returns {ReadableStream} A readable stream that emits {@link File} instances.
*
* @example
* const {Storage} = require('@google-cloud/storage');
* const storage = new Storage();
* const bucket = storage.bucket('albums');
*
* bucket.getFilesStream()
* .on('error', console.error)
* .on('data', function(file) {
* // file is a File object.
* })
* .on('end', function() {
* // All files retrieved.
* });
*
* //-
* // If you anticipate many results, you can end a stream early to prevent
* // unnecessary processing and API requests.
* //-
* bucket.getFilesStream()
* .on('data', function(file) {
* this.end();
* });
*
* //-
* // If you're filtering files with a delimiter, you should use
* // {@link Bucket#getFiles} and set `autoPaginate: false` in order to
* // preserve the `apiResponse` argument.
* //-
* const prefixes = [];
*
* function callback(err, files, nextQuery, apiResponse) {
* prefixes = prefixes.concat(apiResponse.prefixes);
*
* if (nextQuery) {
* bucket.getFiles(nextQuery, callback);
* } else {
* // prefixes = The finished array of prefixes.
* }
* }
*
* bucket.getFiles({
* autoPaginate: false,
* delimiter: '/'
* }, callback);
*/
getFilesStream: Function;
signer?: URLSigner;
constructor(storage: Storage, name: string, options?: BucketOptions);
addLifecycleRule(
rule: LifecycleRule,
options?: AddLifecycleRuleOptions
): Promise<SetBucketMetadataResponse>;
addLifecycleRule(
rule: LifecycleRule,
options: AddLifecycleRuleOptions,
callback: SetBucketMetadataCallback
): void;
addLifecycleRule(
rule: LifecycleRule,
callback: SetBucketMetadataCallback
): void;
combine(
sources: string[] | File[],
destination: string | File,
options?: CombineOptions
): Promise<CombineResponse>;
combine(
sources: string[] | File[],
destination: string | File,
options: CombineOptions,
callback: CombineCallback
): void;
combine(
sources: string[] | File[],
destination: string | File,
callback: CombineCallback
): void;
createChannel(
id: string,
config: CreateChannelConfig,
options?: CreateChannelOptions
): Promise<CreateChannelResponse>;
createChannel(
id: string,
config: CreateChannelConfig,
callback: CreateChannelCallback
): void;
createChannel(
id: string,
config: CreateChannelConfig,
options: CreateChannelOptions,
callback: CreateChannelCallback
): void;
createNotification(
topic: string,
options?: CreateNotificationOptions
): Promise<CreateNotificationResponse>;
createNotification(
topic: string,
options: CreateNotificationOptions,
callback: CreateNotificationCallback
): void;
createNotification(topic: string, callback: CreateNotificationCallback): void;
deleteFiles(query?: DeleteFilesOptions): Promise<void>;
deleteFiles(callback: DeleteFilesCallback): void;
deleteFiles(query: DeleteFilesOptions, callback: DeleteFilesCallback): void;
deleteLabels(labels?: string | string[]): Promise<DeleteLabelsResponse>;
deleteLabels(callback: DeleteLabelsCallback): void;
deleteLabels(labels: string | string[], callback: DeleteLabelsCallback): void;
disableRequesterPays(): Promise<DisableRequesterPaysResponse>;
disableRequesterPays(callback: DisableRequesterPaysCallback): void;
enableLogging(
config: EnableLoggingOptions
): Promise<SetBucketMetadataResponse>;
enableLogging(
config: EnableLoggingOptions,
callback: SetBucketMetadataCallback
): void;
enableRequesterPays(): Promise<EnableRequesterPaysResponse>;
enableRequesterPays(callback: EnableRequesterPaysCallback): void;
/**
* Create a {@link File} object. See {@link File} to see how to handle
* the different use cases you may have.
*
* @param {string} name The name of the file in this bucket.
* @param {object} [options] Configuration options.
* @param {string|number} [options.generation] Only use a specific revision of
* this file.
* @param {string} [options.encryptionKey] A custom encryption key. See
* [Customer-supplied Encryption
* Keys](https://cloud.google.com/storage/docs/encryption#customer-supplied).
* @param {string} [options.kmsKeyName] The name of the Cloud KMS key that will
* be used to encrypt the object. Must be in the format:
* `projects/my-project/locations/location/keyRings/my-kr/cryptoKeys/my-key`.
* KMS key ring must use the same location as the bucket.
* @returns {File}
*
* @example
* const {Storage} = require('@google-cloud/storage');
* const storage = new Storage();
* const bucket = storage.bucket('albums');
* const file = bucket.file('my-existing-file.png');
*/
file(name: string, options?: FileOptions): File;
getFiles(query?: GetFilesOptions): Promise<GetFilesResponse>;
getFiles(query: GetFilesOptions, callback: GetFilesCallback): void;
getFiles(callback: GetFilesCallback): void;
getLabels(options: GetLabelsOptions): Promise<GetLabelsResponse>;
getLabels(callback: GetLabelsCallback): void;
getLabels(options: GetLabelsOptions, callback: GetLabelsCallback): void;
getNotifications(
options?: GetNotificationsOptions
): Promise<GetNotificationsResponse>;
getNotifications(callback: GetNotificationsCallback): void;
getNotifications(
options: GetNotificationsOptions,
callback: GetNotificationsCallback
): void;
getSignedUrl(cfg: GetBucketSignedUrlConfig): Promise<GetSignedUrlResponse>;
getSignedUrl(
cfg: GetBucketSignedUrlConfig,
callback: GetSignedUrlCallback
): void;
lock(metageneration: number | string): Promise<BucketLockResponse>;
lock(metageneration: number | string, callback: BucketLockCallback): void;
makePrivate(
options?: MakeBucketPrivateOptions
): Promise<MakeBucketPrivateResponse>;
makePrivate(callback: MakeBucketPrivateCallback): void;
makePrivate(
options: MakeBucketPrivateOptions,
callback: MakeBucketPrivateCallback
): void;
makePublic(
options?: MakeBucketPublicOptions
): Promise<MakeBucketPublicResponse>;
makePublic(callback: MakeBucketPublicCallback): void;
makePublic(
options: MakeBucketPublicOptions,
callback: MakeBucketPublicCallback
): void;
/**
* Get a reference to a Cloud Pub/Sub Notification.
*
* @param {string} id ID of notification.
* @returns {Notification}
* @see Notification
*
* @example
* const {Storage} = require('@google-cloud/storage');
* const storage = new Storage();
* const bucket = storage.bucket('my-bucket');
* const notification = bucket.notification('1');
*/
notification(id: string): Notification;
removeRetentionPeriod(): Promise<SetBucketMetadataResponse>;
removeRetentionPeriod(callback: SetBucketMetadataCallback): void;
request(reqOpts: DecorateRequestOptions): Promise<[ResponseBody, Metadata]>;
request(
reqOpts: DecorateRequestOptions,
callback: BodyResponseCallback
): void;
setLabels(
labels: Labels,
options?: SetLabelsOptions
): Promise<SetLabelsResponse>;
setLabels(labels: Labels, callback: SetLabelsCallback): void;
setLabels(
labels: Labels,
options: SetLabelsOptions,
callback: SetLabelsCallback
): void;
setRetentionPeriod(duration: number): Promise<SetBucketMetadataResponse>;
setRetentionPeriod(
duration: number,
callback: SetBucketMetadataCallback
): void;
setCorsConfiguration(
corsConfiguration: Cors[]
): Promise<SetBucketMetadataResponse>;
setCorsConfiguration(
corsConfiguration: Cors[],
callback: SetBucketMetadataCallback
): void;
setStorageClass(
storageClass: string,
options?: SetBucketStorageClassOptions
): Promise<SetBucketMetadataResponse>;
setStorageClass(
storageClass: string,
callback: SetBucketStorageClassCallback
): void;
setStorageClass(
storageClass: string,
options: SetBucketStorageClassOptions,
callback: SetBucketStorageClassCallback
): void;
/**
* Set a user project to be billed for all requests made from this Bucket
* object and any files referenced from this Bucket object.
*
* @param {string} userProject The user project.
*
* @example
* const {Storage} = require('@google-cloud/storage');
* const storage = new Storage();
* const bucket = storage.bucket('albums');
*
* bucket.setUserProject('grape-spaceship-123');
*/
setUserProject(userProject: string): void;
upload(pathString: string, options?: UploadOptions): Promise<UploadResponse>;
upload(
pathString: string,
options: UploadOptions,
callback: UploadCallback
): void;
upload(pathString: string, callback: UploadCallback): void;
makeAllFilesPublicPrivate_(
options?: MakeAllFilesPublicPrivateOptions
): Promise<MakeAllFilesPublicPrivateResponse>;
makeAllFilesPublicPrivate_(callback: MakeAllFilesPublicPrivateCallback): void;
makeAllFilesPublicPrivate_(
options: MakeAllFilesPublicPrivateOptions,
callback: MakeAllFilesPublicPrivateCallback
): void;
getId(): string;
}
/*! firebase-admin v9.4.2 */
declare namespace firebasestorage {
/**
* The default `Storage` service if no
* app is provided or the `Storage` service associated with the provided
* app.
*/
export class Storage {
/**
* Optional app whose `Storage` service to
* return. If not provided, the default `Storage` service will be returned.
*/
app: app.App;
/**
* @returns A [Bucket](https://cloud.google.com/nodejs/docs/reference/storage/latest/Bucket)
* instance as defined in the `@google-cloud/storage` package.
*/
bucket(name?: string): Bucket;
}
}

1557
src/components/CodeEditor/firestore.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,535 @@
{
"inherit": true,
"base": "vs-dark",
"colors": {
"focusBorder": "#1f6feb",
"foreground": "#c9d1d9",
"descriptionForeground": "#8b949e",
"errorForeground": "#f85149",
"textLink.foreground": "#58a6ff",
"textLink.activeForeground": "#58a6ff",
"textBlockQuote.background": "#010409",
"textBlockQuote.border": "#30363d",
"textCodeBlock.background": "#6e768166",
"textPreformat.foreground": "#8b949e",
"textSeparator.foreground": "#21262d",
"button.background": "#238636",
"button.foreground": "#ffffff",
"button.hoverBackground": "#2ea043",
"button.secondaryBackground": "#282e33",
"button.secondaryForeground": "#c9d1d9",
"button.secondaryHoverBackground": "#30363d",
"checkbox.background": "#161b22",
"checkbox.border": "#30363d",
"dropdown.background": "#161b22",
"dropdown.border": "#30363d",
"dropdown.foreground": "#c9d1d9",
"dropdown.listBackground": "#161b22",
"input.background": "#0d1117",
"input.border": "#30363d",
"input.foreground": "#c9d1d9",
"input.placeholderForeground": "#484f58",
"badge.foreground": "#f0f6fc",
"badge.background": "#1f6feb",
"progressBar.background": "#1f6feb",
"titleBar.activeForeground": "#8b949e",
"titleBar.activeBackground": "#0d1117",
"titleBar.inactiveForeground": "#8b949e",
"titleBar.inactiveBackground": "#010409",
"titleBar.border": "#30363d",
"activityBar.foreground": "#c9d1d9",
"activityBar.inactiveForeground": "#8b949e",
"activityBar.background": "#0d1117",
"activityBarBadge.foreground": "#f0f6fc",
"activityBarBadge.background": "#1f6feb",
"activityBar.activeBorder": "#f78166",
"activityBar.border": "#30363d",
"sideBar.foreground": "#c9d1d9",
"sideBar.background": "#010409",
"sideBar.border": "#30363d",
"sideBarTitle.foreground": "#c9d1d9",
"sideBarSectionHeader.foreground": "#c9d1d9",
"sideBarSectionHeader.background": "#010409",
"sideBarSectionHeader.border": "#30363d",
"list.hoverForeground": "#c9d1d9",
"list.inactiveSelectionForeground": "#c9d1d9",
"list.activeSelectionForeground": "#c9d1d9",
"list.hoverBackground": "#6e76811a",
"list.inactiveSelectionBackground": "#6e768166",
"list.activeSelectionBackground": "#6e768166",
"list.focusForeground": "#c9d1d9",
"list.focusBackground": "#388bfd26",
"list.inactiveFocusBackground": "#388bfd26",
"list.highlightForeground": "#58a6ff",
"tree.indentGuidesStroke": "#21262d",
"notificationCenterHeader.foreground": "#8b949e",
"notificationCenterHeader.background": "#161b22",
"notifications.foreground": "#c9d1d9",
"notifications.background": "#161b22",
"notifications.border": "#30363d",
"notificationsErrorIcon.foreground": "#f85149",
"notificationsWarningIcon.foreground": "#d29922",
"notificationsInfoIcon.foreground": "#58a6ff",
"pickerGroup.border": "#30363d",
"pickerGroup.foreground": "#8b949e",
"quickInput.background": "#161b22",
"quickInput.foreground": "#c9d1d9",
"statusBar.foreground": "#8b949e",
"statusBar.background": "#0d1117",
"statusBar.border": "#30363d",
"statusBar.noFolderBackground": "#0d1117",
"statusBar.debuggingBackground": "#da3633",
"statusBar.debuggingForeground": "#f0f6fc",
"statusBarItem.prominentBackground": "#161b22",
"editorGroupHeader.tabsBackground": "#010409",
"editorGroupHeader.tabsBorder": "#30363d",
"editorGroup.border": "#30363d",
"tab.activeForeground": "#c9d1d9",
"tab.inactiveForeground": "#8b949e",
"tab.inactiveBackground": "#010409",
"tab.activeBackground": "#0d1117",
"tab.hoverBackground": "#0d1117",
"tab.unfocusedHoverBackground": "#6e76811a",
"tab.border": "#30363d",
"tab.unfocusedActiveBorderTop": "#30363d",
"tab.activeBorder": "#0d1117",
"tab.unfocusedActiveBorder": "#0d1117",
"tab.activeBorderTop": "#f78166",
"breadcrumb.foreground": "#8b949e",
"breadcrumb.focusForeground": "#c9d1d9",
"breadcrumb.activeSelectionForeground": "#8b949e",
"breadcrumbPicker.background": "#161b22",
"editor.foreground": "#c9d1d9",
"editor.background": "#0d1117",
"editorWidget.background": "#161b22",
"editor.foldBackground": "#6e76811a",
"editor.lineHighlightBackground": "#6e76811a",
"editorLineNumber.foreground": "#8b949e",
"editorLineNumber.activeForeground": "#c9d1d9",
"editorIndentGuide.background": "#21262d",
"editorIndentGuide.activeBackground": "#30363d",
"editorWhitespace.foreground": "#484f58",
"editorCursor.foreground": "#58a6ff",
"editor.findMatchBackground": "#ffd33d44",
"editor.findMatchHighlightBackground": "#ffd33d22",
"editor.linkedEditingBackground": "#3392FF22",
"editor.inactiveSelectionBackground": "#3392FF22",
"editor.selectionBackground": "#3392FF44",
"editor.selectionHighlightBackground": "#17E5E633",
"editor.selectionHighlightBorder": "#17E5E600",
"editor.wordHighlightBackground": "#17E5E600",
"editor.wordHighlightStrongBackground": "#17E5E600",
"editor.wordHighlightBorder": "#17E5E699",
"editor.wordHighlightStrongBorder": "#17E5E666",
"editorBracketMatch.background": "#17E5E650",
"editorBracketMatch.border": "#17E5E600",
"editorGutter.modifiedBackground": "#bb800966",
"editorGutter.addedBackground": "#2ea04366",
"editorGutter.deletedBackground": "#f8514966",
"diffEditor.insertedTextBackground": "#2ea04326",
"diffEditor.removedTextBackground": "#f8514926",
"scrollbar.shadow": "#0008",
"scrollbarSlider.background": "#484F5833",
"scrollbarSlider.hoverBackground": "#484F5844",
"scrollbarSlider.activeBackground": "#484F5888",
"editorOverviewRuler.border": "#010409",
"panel.background": "#010409",
"panel.border": "#30363d",
"panelTitle.activeBorder": "#f78166",
"panelTitle.activeForeground": "#c9d1d9",
"panelTitle.inactiveForeground": "#8b949e",
"panelInput.border": "#30363d",
"terminal.foreground": "#8b949e",
"terminal.ansiBlack": "#484f58",
"terminal.ansiRed": "#ff7b72",
"terminal.ansiGreen": "#3fb950",
"terminal.ansiYellow": "#d29922",
"terminal.ansiBlue": "#58a6ff",
"terminal.ansiMagenta": "#bc8cff",
"terminal.ansiCyan": "#39c5cf",
"terminal.ansiWhite": "#b1bac4",
"terminal.ansiBrightBlack": "#6e7681",
"terminal.ansiBrightRed": "#ffa198",
"terminal.ansiBrightGreen": "#56d364",
"terminal.ansiBrightYellow": "#e3b341",
"terminal.ansiBrightBlue": "#79c0ff",
"terminal.ansiBrightMagenta": "#d2a8ff",
"terminal.ansiBrightCyan": "#56d4dd",
"terminal.ansiBrightWhite": "#f0f6fc",
"gitDecoration.addedResourceForeground": "#3fb950",
"gitDecoration.modifiedResourceForeground": "#d29922",
"gitDecoration.deletedResourceForeground": "#f85149",
"gitDecoration.untrackedResourceForeground": "#3fb950",
"gitDecoration.ignoredResourceForeground": "#484f58",
"gitDecoration.conflictingResourceForeground": "#db6d28",
"gitDecoration.submoduleResourceForeground": "#8b949e",
"debugToolBar.background": "#161b22",
"editor.stackFrameHighlightBackground": "#D2992225",
"editor.focusedStackFrameHighlightBackground": "#3FB95025",
"peekViewEditor.matchHighlightBackground": "#ffd33d33",
"peekViewResult.matchHighlightBackground": "#ffd33d33",
"peekViewEditor.background": "#0d111788",
"peekViewResult.background": "#0d1117",
"settings.headerForeground": "#8b949e",
"settings.modifiedItemIndicator": "#bb800966",
"welcomePage.buttonBackground": "#21262d",
"welcomePage.buttonHoverBackground": "#30363d"
},
"rules": [
{
"foreground": "#8b949e",
"token": "comment"
},
{
"foreground": "#8b949e",
"token": "punctuation.definition.comment"
},
{
"foreground": "#8b949e",
"token": "string.comment"
},
{
"foreground": "#79c0ff",
"token": "constant"
},
{
"foreground": "#79c0ff",
"token": "entity.name.constant"
},
{
"foreground": "#79c0ff",
"token": "variable.other.constant"
},
{
"foreground": "#79c0ff",
"token": "variable.language"
},
{
"foreground": "#79c0ff",
"token": "entity"
},
{
"foreground": "#ffa657",
"token": "entity.name"
},
{
"foreground": "#ffa657",
"token": "meta.export.default"
},
{
"foreground": "#ffa657",
"token": "meta.definition.variable"
},
{
"foreground": "#c9d1d9",
"token": "variable.parameter.function"
},
{
"foreground": "#c9d1d9",
"token": "meta.jsx.children"
},
{
"foreground": "#c9d1d9",
"token": "meta.block"
},
{
"foreground": "#c9d1d9",
"token": "meta.tag.attributes"
},
{
"foreground": "#c9d1d9",
"token": "entity.name.constant"
},
{
"foreground": "#c9d1d9",
"token": "meta.object.member"
},
{
"foreground": "#c9d1d9",
"token": "meta.embedded.expression"
},
{
"foreground": "#d2a8ff",
"token": "entity.name.function"
},
{
"foreground": "#7ee787",
"token": "entity.name.tag"
},
{
"foreground": "#7ee787",
"token": "support.class.component"
},
{
"foreground": "#ff7b72",
"token": "keyword"
},
{
"foreground": "#ff7b72",
"token": "storage"
},
{
"foreground": "#ff7b72",
"token": "storage.type"
},
{
"foreground": "#c9d1d9",
"token": "storage.modifier.package"
},
{
"foreground": "#c9d1d9",
"token": "storage.modifier.import"
},
{
"foreground": "#c9d1d9",
"token": "storage.type.java"
},
{
"foreground": "#a5d6ff",
"token": "string"
},
{
"foreground": "#a5d6ff",
"token": "punctuation.definition.string"
},
{
"foreground": "#a5d6ff",
"token": "string punctuation.section.embedded source"
},
{
"foreground": "#79c0ff",
"token": "support"
},
{
"foreground": "#79c0ff",
"token": "meta.property-name"
},
{
"foreground": "#ffa657",
"token": "variable"
},
{
"foreground": "#c9d1d9",
"token": "variable.other"
},
{
"fontStyle": "italic",
"foreground": "#ffa198",
"token": "invalid.broken"
},
{
"fontStyle": "italic",
"foreground": "#ffa198",
"token": "invalid.deprecated"
},
{
"fontStyle": "italic",
"foreground": "#ffa198",
"token": "invalid.illegal"
},
{
"fontStyle": "italic",
"foreground": "#ffa198",
"token": "invalid.unimplemented"
},
{
"fontStyle": "italic underline",
"background": "#ff7b72",
"foreground": "#0d1117",
"content": "^M",
"token": "carriage-return"
},
{
"foreground": "#ffa198",
"token": "message.error"
},
{
"foreground": "#c9d1d9",
"token": "string source"
},
{
"foreground": "#79c0ff",
"token": "string variable"
},
{
"foreground": "#a5d6ff",
"token": "source.regexp"
},
{
"foreground": "#a5d6ff",
"token": "string.regexp"
},
{
"foreground": "#a5d6ff",
"token": "string.regexp.character-class"
},
{
"foreground": "#a5d6ff",
"token": "string.regexp constant.character.escape"
},
{
"foreground": "#a5d6ff",
"token": "string.regexp source.ruby.embedded"
},
{
"foreground": "#a5d6ff",
"token": "string.regexp string.regexp.arbitrary-repitition"
},
{
"fontStyle": "bold",
"foreground": "#7ee787",
"token": "string.regexp constant.character.escape"
},
{
"foreground": "#79c0ff",
"token": "support.constant"
},
{
"foreground": "#79c0ff",
"token": "support.variable"
},
{
"foreground": "#79c0ff",
"token": "meta.module-reference"
},
{
"foreground": "#ffa657",
"token": "punctuation.definition.list.begin.markdown"
},
{
"fontStyle": "bold",
"foreground": "#79c0ff",
"token": "markup.heading"
},
{
"fontStyle": "bold",
"foreground": "#79c0ff",
"token": "markup.heading entity.name"
},
{
"foreground": "#7ee787",
"token": "markup.quote"
},
{
"fontStyle": "italic",
"foreground": "#c9d1d9",
"token": "markup.italic"
},
{
"fontStyle": "bold",
"foreground": "#c9d1d9",
"token": "markup.bold"
},
{
"foreground": "#79c0ff",
"token": "markup.raw"
},
{
"background": "#490202",
"foreground": "#ffa198",
"token": "markup.deleted"
},
{
"background": "#490202",
"foreground": "#ffa198",
"token": "meta.diff.header.from-file"
},
{
"background": "#490202",
"foreground": "#ffa198",
"token": "punctuation.definition.deleted"
},
{
"background": "#04260f",
"foreground": "#7ee787",
"token": "markup.inserted"
},
{
"background": "#04260f",
"foreground": "#7ee787",
"token": "meta.diff.header.to-file"
},
{
"background": "#04260f",
"foreground": "#7ee787",
"token": "punctuation.definition.inserted"
},
{
"background": "#5a1e02",
"foreground": "#ffa657",
"token": "markup.changed"
},
{
"background": "#5a1e02",
"foreground": "#ffa657",
"token": "punctuation.definition.changed"
},
{
"foreground": "#161b22",
"background": "#79c0ff",
"token": "markup.ignored"
},
{
"foreground": "#161b22",
"background": "#79c0ff",
"token": "markup.untracked"
},
{
"foreground": "#d2a8ff",
"fontStyle": "bold",
"token": "meta.diff.range"
},
{
"foreground": "#79c0ff",
"token": "meta.diff.header"
},
{
"fontStyle": "bold",
"foreground": "#79c0ff",
"token": "meta.separator"
},
{
"foreground": "#79c0ff",
"token": "meta.output"
},
{
"foreground": "#8b949e",
"token": "brackethighlighter.tag"
},
{
"foreground": "#8b949e",
"token": "brackethighlighter.curly"
},
{
"foreground": "#8b949e",
"token": "brackethighlighter.round"
},
{
"foreground": "#8b949e",
"token": "brackethighlighter.square"
},
{
"foreground": "#8b949e",
"token": "brackethighlighter.angle"
},
{
"foreground": "#8b949e",
"token": "brackethighlighter.quote"
},
{
"foreground": "#ffa198",
"token": "brackethighlighter.unmatched"
},
{
"foreground": "#a5d6ff",
"fontStyle": "underline",
"token": "constant.other.reference.link"
},
{
"foreground": "#a5d6ff",
"fontStyle": "underline",
"token": "string.other.link"
}
],
"encodedTokensColors": []
}

View File

@@ -0,0 +1,531 @@
{
"inherit": true,
"base": "vs",
"colors": {
"focusBorder": "#0969da",
"foreground": "#24292f",
"descriptionForeground": "#57606a",
"errorForeground": "#cf222e",
"textLink.foreground": "#0969da",
"textLink.activeForeground": "#0969da",
"textBlockQuote.background": "#f6f8fa",
"textBlockQuote.border": "#d0d7de",
"textCodeBlock.background": "#afb8c133",
"textPreformat.foreground": "#57606a",
"textSeparator.foreground": "#d8dee4",
"button.background": "#2da44e",
"button.foreground": "#ffffff",
"button.hoverBackground": "#2c974b",
"button.secondaryBackground": "#ebecf0",
"button.secondaryForeground": "#24292f",
"button.secondaryHoverBackground": "#f3f4f6",
"checkbox.background": "#f6f8fa",
"checkbox.border": "#d0d7de",
"dropdown.background": "#ffffff",
"dropdown.border": "#d0d7de",
"dropdown.foreground": "#24292f",
"dropdown.listBackground": "#ffffff",
"input.background": "#ffffff",
"input.border": "#d0d7de",
"input.foreground": "#24292f",
"input.placeholderForeground": "#6e7781",
"badge.foreground": "#ffffff",
"badge.background": "#0969da",
"progressBar.background": "#0969da",
"titleBar.activeForeground": "#57606a",
"titleBar.activeBackground": "#ffffff",
"titleBar.inactiveForeground": "#57606a",
"titleBar.inactiveBackground": "#f6f8fa",
"titleBar.border": "#d0d7de",
"activityBar.foreground": "#24292f",
"activityBar.inactiveForeground": "#57606a",
"activityBar.background": "#ffffff",
"activityBarBadge.foreground": "#ffffff",
"activityBarBadge.background": "#0969da",
"activityBar.activeBorder": "#fd8c73",
"activityBar.border": "#d0d7de",
"sideBar.foreground": "#24292f",
"sideBar.background": "#f6f8fa",
"sideBar.border": "#d0d7de",
"sideBarTitle.foreground": "#24292f",
"sideBarSectionHeader.foreground": "#24292f",
"sideBarSectionHeader.background": "#f6f8fa",
"sideBarSectionHeader.border": "#d0d7de",
"list.hoverForeground": "#24292f",
"list.inactiveSelectionForeground": "#24292f",
"list.activeSelectionForeground": "#24292f",
"list.hoverBackground": "#eaeef280",
"list.inactiveSelectionBackground": "#afb8c133",
"list.activeSelectionBackground": "#afb8c133",
"list.focusForeground": "#24292f",
"list.focusBackground": "#ddf4ff",
"list.inactiveFocusBackground": "#ddf4ff",
"list.highlightForeground": "#0969da",
"tree.indentGuidesStroke": "#d8dee4",
"notificationCenterHeader.foreground": "#57606a",
"notificationCenterHeader.background": "#f6f8fa",
"notifications.foreground": "#24292f",
"notifications.background": "#ffffff",
"notifications.border": "#d0d7de",
"notificationsErrorIcon.foreground": "#cf222e",
"notificationsWarningIcon.foreground": "#9a6700",
"notificationsInfoIcon.foreground": "#0969da",
"pickerGroup.border": "#d0d7de",
"pickerGroup.foreground": "#57606a",
"quickInput.background": "#ffffff",
"quickInput.foreground": "#24292f",
"statusBar.foreground": "#57606a",
"statusBar.background": "#ffffff",
"statusBar.border": "#d0d7de",
"statusBar.noFolderBackground": "#ffffff",
"statusBar.debuggingBackground": "#cf222e",
"statusBar.debuggingForeground": "#ffffff",
"statusBarItem.prominentBackground": "#f6f8fa",
"editorGroupHeader.tabsBackground": "#f6f8fa",
"editorGroupHeader.tabsBorder": "#d0d7de",
"editorGroup.border": "#d0d7de",
"tab.activeForeground": "#24292f",
"tab.inactiveForeground": "#57606a",
"tab.inactiveBackground": "#f6f8fa",
"tab.activeBackground": "#ffffff",
"tab.hoverBackground": "#ffffff",
"tab.unfocusedHoverBackground": "#eaeef280",
"tab.border": "#d0d7de",
"tab.unfocusedActiveBorderTop": "#d0d7de",
"tab.activeBorder": "#ffffff",
"tab.unfocusedActiveBorder": "#ffffff",
"tab.activeBorderTop": "#fd8c73",
"breadcrumb.foreground": "#57606a",
"breadcrumb.focusForeground": "#24292f",
"breadcrumb.activeSelectionForeground": "#57606a",
"breadcrumbPicker.background": "#ffffff",
"editor.foreground": "#24292f",
"editor.background": "#ffffff",
"editorWidget.background": "#ffffff",
"editor.foldBackground": "#6e77811a",
"editor.lineHighlightBackground": "#eaeef280",
"editorLineNumber.foreground": "#57606a",
"editorLineNumber.activeForeground": "#24292f",
"editorIndentGuide.background": "#d8dee4",
"editorIndentGuide.activeBackground": "#d0d7de",
"editorWhitespace.foreground": "#6e7781",
"editorCursor.foreground": "#0969da",
"editor.findMatchBackground": "#bf8700",
"editor.findMatchHighlightBackground": "#ffdf5d66",
"editor.linkedEditingBackground": "#0366d611",
"editor.inactiveSelectionBackground": "#0366d611",
"editor.selectionBackground": "#0366d625",
"editor.selectionHighlightBackground": "#34d05840",
"editor.selectionHighlightBorder": "#34d05800",
"editor.wordHighlightBackground": "#34d05800",
"editor.wordHighlightStrongBackground": "#34d05800",
"editor.wordHighlightBorder": "#24943e99",
"editor.wordHighlightStrongBorder": "#24943e50",
"editorBracketMatch.background": "#34d05840",
"editorBracketMatch.border": "#34d05800",
"editorGutter.modifiedBackground": "#d4a72c66",
"editorGutter.addedBackground": "#4ac26b66",
"editorGutter.deletedBackground": "#ff818266",
"diffEditor.insertedTextBackground": "#85e89d33",
"diffEditor.removedTextBackground": "#f9758326",
"scrollbar.shadow": "#6a737d33",
"scrollbarSlider.background": "#959da533",
"scrollbarSlider.hoverBackground": "#959da544",
"scrollbarSlider.activeBackground": "#959da588",
"editorOverviewRuler.border": "#ffffff",
"panel.background": "#f6f8fa",
"panel.border": "#d0d7de",
"panelTitle.activeBorder": "#fd8c73",
"panelTitle.activeForeground": "#24292f",
"panelTitle.inactiveForeground": "#57606a",
"panelInput.border": "#d0d7de",
"terminal.foreground": "#57606a",
"terminal.ansiBlack": "#24292f",
"terminal.ansiRed": "#cf222e",
"terminal.ansiGreen": "#116329",
"terminal.ansiYellow": "#4d2d00",
"terminal.ansiBlue": "#0969da",
"terminal.ansiMagenta": "#8250df",
"terminal.ansiCyan": "#1b7c83",
"terminal.ansiWhite": "#6e7781",
"terminal.ansiBrightBlack": "#57606a",
"terminal.ansiBrightRed": "#a40e26",
"terminal.ansiBrightGreen": "#1a7f37",
"terminal.ansiBrightYellow": "#633c01",
"terminal.ansiBrightBlue": "#218bff",
"terminal.ansiBrightMagenta": "#a475f9",
"terminal.ansiBrightCyan": "#3192aa",
"terminal.ansiBrightWhite": "#8c959f",
"gitDecoration.addedResourceForeground": "#1a7f37",
"gitDecoration.modifiedResourceForeground": "#9a6700",
"gitDecoration.deletedResourceForeground": "#cf222e",
"gitDecoration.untrackedResourceForeground": "#1a7f37",
"gitDecoration.ignoredResourceForeground": "#6e7781",
"gitDecoration.conflictingResourceForeground": "#bc4c00",
"gitDecoration.submoduleResourceForeground": "#57606a",
"debugToolBar.background": "#ffffff",
"editor.stackFrameHighlightBackground": "#ffd33d33",
"editor.focusedStackFrameHighlightBackground": "#28a74525",
"settings.headerForeground": "#57606a",
"settings.modifiedItemIndicator": "#d4a72c66",
"welcomePage.buttonBackground": "#f6f8fa",
"welcomePage.buttonHoverBackground": "#f3f4f6"
},
"rules": [
{
"foreground": "#6e7781",
"token": "comment"
},
{
"foreground": "#6e7781",
"token": "punctuation.definition.comment"
},
{
"foreground": "#6e7781",
"token": "string.comment"
},
{
"foreground": "#0550ae",
"token": "constant"
},
{
"foreground": "#0550ae",
"token": "entity.name.constant"
},
{
"foreground": "#0550ae",
"token": "variable.other.constant"
},
{
"foreground": "#0550ae",
"token": "variable.language"
},
{
"foreground": "#0550ae",
"token": "entity"
},
{
"foreground": "#953800",
"token": "entity.name"
},
{
"foreground": "#953800",
"token": "meta.export.default"
},
{
"foreground": "#953800",
"token": "meta.definition.variable"
},
{
"foreground": "#24292f",
"token": "variable.parameter.function"
},
{
"foreground": "#24292f",
"token": "meta.jsx.children"
},
{
"foreground": "#24292f",
"token": "meta.block"
},
{
"foreground": "#24292f",
"token": "meta.tag.attributes"
},
{
"foreground": "#24292f",
"token": "entity.name.constant"
},
{
"foreground": "#24292f",
"token": "meta.object.member"
},
{
"foreground": "#24292f",
"token": "meta.embedded.expression"
},
{
"foreground": "#8250df",
"token": "entity.name.function"
},
{
"foreground": "#116329",
"token": "entity.name.tag"
},
{
"foreground": "#116329",
"token": "support.class.component"
},
{
"foreground": "#cf222e",
"token": "keyword"
},
{
"foreground": "#cf222e",
"token": "storage"
},
{
"foreground": "#cf222e",
"token": "storage.type"
},
{
"foreground": "#24292f",
"token": "storage.modifier.package"
},
{
"foreground": "#24292f",
"token": "storage.modifier.import"
},
{
"foreground": "#24292f",
"token": "storage.type.java"
},
{
"foreground": "#0a3069",
"token": "string"
},
{
"foreground": "#0a3069",
"token": "punctuation.definition.string"
},
{
"foreground": "#0a3069",
"token": "string punctuation.section.embedded source"
},
{
"foreground": "#0550ae",
"token": "support"
},
{
"foreground": "#0550ae",
"token": "meta.property-name"
},
{
"foreground": "#953800",
"token": "variable"
},
{
"foreground": "#24292f",
"token": "variable.other"
},
{
"fontStyle": "italic",
"foreground": "#82071e",
"token": "invalid.broken"
},
{
"fontStyle": "italic",
"foreground": "#82071e",
"token": "invalid.deprecated"
},
{
"fontStyle": "italic",
"foreground": "#82071e",
"token": "invalid.illegal"
},
{
"fontStyle": "italic",
"foreground": "#82071e",
"token": "invalid.unimplemented"
},
{
"fontStyle": "italic underline",
"background": "#cf222e",
"foreground": "#f6f8fa",
"content": "^M",
"token": "carriage-return"
},
{
"foreground": "#82071e",
"token": "message.error"
},
{
"foreground": "#24292f",
"token": "string source"
},
{
"foreground": "#0550ae",
"token": "string variable"
},
{
"foreground": "#0a3069",
"token": "source.regexp"
},
{
"foreground": "#0a3069",
"token": "string.regexp"
},
{
"foreground": "#0a3069",
"token": "string.regexp.character-class"
},
{
"foreground": "#0a3069",
"token": "string.regexp constant.character.escape"
},
{
"foreground": "#0a3069",
"token": "string.regexp source.ruby.embedded"
},
{
"foreground": "#0a3069",
"token": "string.regexp string.regexp.arbitrary-repitition"
},
{
"fontStyle": "bold",
"foreground": "#116329",
"token": "string.regexp constant.character.escape"
},
{
"foreground": "#0550ae",
"token": "support.constant"
},
{
"foreground": "#0550ae",
"token": "support.variable"
},
{
"foreground": "#0550ae",
"token": "meta.module-reference"
},
{
"foreground": "#953800",
"token": "punctuation.definition.list.begin.markdown"
},
{
"fontStyle": "bold",
"foreground": "#0550ae",
"token": "markup.heading"
},
{
"fontStyle": "bold",
"foreground": "#0550ae",
"token": "markup.heading entity.name"
},
{
"foreground": "#116329",
"token": "markup.quote"
},
{
"fontStyle": "italic",
"foreground": "#24292f",
"token": "markup.italic"
},
{
"fontStyle": "bold",
"foreground": "#24292f",
"token": "markup.bold"
},
{
"foreground": "#0550ae",
"token": "markup.raw"
},
{
"background": "#FFEBE9",
"foreground": "#82071e",
"token": "markup.deleted"
},
{
"background": "#FFEBE9",
"foreground": "#82071e",
"token": "meta.diff.header.from-file"
},
{
"background": "#FFEBE9",
"foreground": "#82071e",
"token": "punctuation.definition.deleted"
},
{
"background": "#dafbe1",
"foreground": "#116329",
"token": "markup.inserted"
},
{
"background": "#dafbe1",
"foreground": "#116329",
"token": "meta.diff.header.to-file"
},
{
"background": "#dafbe1",
"foreground": "#116329",
"token": "punctuation.definition.inserted"
},
{
"background": "#ffd8b5",
"foreground": "#953800",
"token": "markup.changed"
},
{
"background": "#ffd8b5",
"foreground": "#953800",
"token": "punctuation.definition.changed"
},
{
"foreground": "#eaeef2",
"background": "#0550ae",
"token": "markup.ignored"
},
{
"foreground": "#eaeef2",
"background": "#0550ae",
"token": "markup.untracked"
},
{
"foreground": "#8250df",
"fontStyle": "bold",
"token": "meta.diff.range"
},
{
"foreground": "#0550ae",
"token": "meta.diff.header"
},
{
"fontStyle": "bold",
"foreground": "#0550ae",
"token": "meta.separator"
},
{
"foreground": "#0550ae",
"token": "meta.output"
},
{
"foreground": "#57606a",
"token": "brackethighlighter.tag"
},
{
"foreground": "#57606a",
"token": "brackethighlighter.curly"
},
{
"foreground": "#57606a",
"token": "brackethighlighter.round"
},
{
"foreground": "#57606a",
"token": "brackethighlighter.square"
},
{
"foreground": "#57606a",
"token": "brackethighlighter.angle"
},
{
"foreground": "#57606a",
"token": "brackethighlighter.quote"
},
{
"foreground": "#82071e",
"token": "brackethighlighter.unmatched"
},
{
"foreground": "#0a3069",
"fontStyle": "underline",
"token": "constant.other.reference.link"
},
{
"foreground": "#0a3069",
"fontStyle": "underline",
"token": "string.other.link"
}
],
"encodedTokensColors": []
}

View File

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

101
src/components/CodeEditor/rowy.d.ts vendored Normal file
View File

@@ -0,0 +1,101 @@
type RowyFile = {
downloadURL: string;
name: string;
type: string;
lastModifiedTS: number;
};
type RowyUser = {
email: any;
emailVerified: boolean;
displayName: string;
photoURL: string;
uid: string;
timestamp: number;
};
type uploadOptions = {
bucket?: string;
folderPath?: string;
fileName?: string;
};
interface Rowy {
metadata: {
/**
* The project ID of the project running this function.
*/
projectId: () => Promise<string>;
/**
* The numeric project ID of the project running this function.
*/
projectNumber: () => Promise<string>;
/**
* The email address of service account running this function.
* This is the service account that is used to call other APIs.
* Ensure that the service account has the correct permissions.
*/
serviceAccountEmail: () => Promise<string>;
/**
* a user object of the service account running this function.
* Compatible with Rowy audit fields
* Can be used to add createdBy or updatedBy fields to a document.
*/
serviceAccountUser: () => Promise<RowyUser>;
};
/**
* Gives access to the Secret Manager.
* manage your secrets in the Google Cloud Console.
*/
secrets: {
/**
* Get an existing secret from the secret manager.
*/
get: (
name: SecretNames,
version?: string
) => Promise<string | any | undefined>;
};
/**
* Gives access to the Cloud Storage.
*/
storage: {
upload: {
/**
* uploads a file to storage bucket from an external url.
*/
url: (
url: string,
options?: uploadOptions
) => Promise<RowyFile | undefined>;
/**
* uploads a file to storage bucket from a buffer or string
*/
data: (
data: Buffer | string,
options?: uploadOptions
) => Promise<RowyFile | undefined>;
};
};
/**
* @deprecated will be removed in version 2.0.
* use rowy.secrets.get instead.
* Get an existing secret from the secret manager.
*/
getSecret: (
name: SecretNames,
version?: string
) => Promise<string | undefined>;
/**
* @deprecated will be removed in version 2.0.
* use rowy.metadata.serviceAccountUser instead.
* Compatible with Rowy audit fields
* Can be used to add createdBy or updatedBy fields to a document.
*/
getServiceAccountUser: () => Promise<RowyUser>;
/**
* @deprecated will be removed in version 2.0.
* use rowy.storage.upload.url instead.
* uploads a file to storage bucket from an external url.
*/
url2storage: (url: string) => Promise<RowyFile | undefined>;
}
declare const rowy: Rowy;

View File

@@ -0,0 +1,327 @@
import { useEffect } from "react";
import {
quicktype,
InputData,
jsonInputForTargetLanguage,
} from "quicktype-core";
import { useMonaco } from "@monaco-editor/react";
import type { languages } from "monaco-editor/esm/vs/editor/editor.api";
import githubLightTheme from "./github-light-default.json";
import githubDarkTheme from "./github-dark-default.json";
import { useTheme } from "@mui/material";
import type { SystemStyleObject, Theme } from "@mui/system";
// TODO:
// import { getColumnType, getFieldProp } from "@src/components/fields";
/* eslint-disable import/no-webpack-loader-syntax */
import firestoreDefs from "!!raw-loader!./firestore.d.ts";
import firebaseAuthDefs from "!!raw-loader!./firebaseAuth.d.ts";
import firebaseStorageDefs from "!!raw-loader!./firebaseStorage.d.ts";
import utilsDefs from "!!raw-loader!./utils.d.ts";
import rowyUtilsDefs from "!!raw-loader!./rowy.d.ts";
import extensionsDefs from "!!raw-loader!./extensions.d.ts";
import { runRoutes } from "@src/constants/runRoutes";
export interface IUseMonacoCustomizationsProps {
minHeight?: number;
disabled?: boolean;
error?: boolean;
extraLibs?: string[];
diagnosticsOptions?: languages.typescript.DiagnosticsOptions;
onUnmount?: () => void;
// Internal only
fullScreen?: boolean;
}
export default function useMonacoCustomizations({
minHeight,
disabled,
error,
extraLibs,
diagnosticsOptions = {
noSemanticValidation: true,
noSyntaxValidation: false,
},
onUnmount,
fullScreen,
}: IUseMonacoCustomizationsProps) {
const theme = useTheme();
const monaco = useMonaco();
useEffect(() => {
return () => {
onUnmount?.();
};
}, []);
// Initialize theme
useEffect(() => {
if (!monaco) {
// useMonaco returns a monaco instance but initialisation is done asynchronously
// dont execute the logic until the instance is initialised
return;
}
setTimeout(() => {
try {
monaco.editor.defineTheme("github-light", githubLightTheme as any);
monaco.editor.defineTheme("github-dark", githubDarkTheme as any);
monaco.editor.setTheme("github-" + theme.palette.mode);
} catch (error) {
console.error("Could not set Monaco theme: ", error);
}
});
}, [monaco, theme.palette.mode]);
// Initialize external libs & TypeScript compiler options
useEffect(() => {
if (!monaco) return;
try {
monaco.languages.typescript.javascriptDefaults.addExtraLib(firestoreDefs);
monaco.languages.typescript.javascriptDefaults.addExtraLib(
firebaseAuthDefs
);
monaco.languages.typescript.javascriptDefaults.addExtraLib(
firebaseStorageDefs
);
// Compiler options
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2020,
allowNonTsExtensions: true,
});
monaco.languages.typescript.javascriptDefaults.addExtraLib(
utilsDefs,
"ts:filename/utils.d.ts"
);
monaco.languages.typescript.javascriptDefaults.addExtraLib(rowyUtilsDefs);
} catch (error) {
console.error(
"An error occurred during initialization of Monaco: ",
error
);
}
}, [monaco]);
// Initialize extraLibs from props
useEffect(() => {
if (!monaco) return;
if (!extraLibs) return;
try {
monaco.languages.typescript.javascriptDefaults.addExtraLib(
extraLibs.join("\n"),
"ts:filename/extraLibs.d.ts"
);
} catch (error) {
console.error("Could not add extraLibs from props: ", error);
}
}, [monaco, extraLibs]);
// Set diagnostics options
const stringifiedDiagnosticsOptions = JSON.stringify(diagnosticsOptions);
useEffect(() => {
if (!monaco) return;
try {
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions(
JSON.parse(stringifiedDiagnosticsOptions)
);
} catch (error) {
console.error("Could not set diagnostics options: ", error);
}
}, [monaco, stringifiedDiagnosticsOptions]);
// TODO:
// const addJsonFieldDefinition = async (columnKey, interfaceName) => {
// const samples = tableState?.rows
// .map((row) => row[columnKey])
// .filter((entry) => entry !== undefined)
// .map((entry) => JSON.stringify(entry));
// if (!samples || samples.length === 0) {
// monaco?.languages.typescript.javascriptDefaults.addExtraLib(
// `type ${interfaceName} = any;`
// );
// return;
// } else {
// const jsonInput = jsonInputForTargetLanguage("typescript");
// await jsonInput.addSource({
// name: interfaceName,
// samples,
// });
// const inputData = new InputData();
// inputData.addInput(jsonInput);
// const result = await quicktype({
// inputData,
// lang: "typescript",
// rendererOptions: { "just-types": "true" },
// });
// const newLib = result.lines.join("\n").replaceAll("export ", "");
// monaco?.languages.typescript.javascriptDefaults.addExtraLib(newLib);
// }
// };
// TODO: types
// const setSecrets = async (monaco, rowyRun) => {
// // set secret options
// try {
// const listSecrets = await rowyRun({
// route: runRoutes.listSecrets,
// });
// const secretsDef = `type SecretNames = ${listSecrets
// .map((secret) => `"${secret}"`)
// .join(" | ")}
// enum secrets {
// ${listSecrets.map((secret) => `${secret} = "${secret}"`).join("\n")}
// }
// `;
// monaco.languages.typescript.javascriptDefaults.addExtraLib(secretsDef);
// } catch (error) {
// console.error("Could not set secret definitions: ", error);
// }
// };
// TODO: types
// const setBaseDefinitions = (monaco, columns) => {
// const rowDefinition =
// [
// Object.keys(columns).map((columnKey: string) => {
// const column = columns[columnKey];
// const type = getColumnType(column);
// if (type === "JSON") {
// const interfaceName =
// columnKey[0].toUpperCase() + columnKey.slice(1);
// addJsonFieldDefinition(columnKey, interfaceName);
// const def = `static "${columnKey}": ${interfaceName}`;
// return def;
// }
// return `static "${columnKey}": ${getFieldProp("dataType", type)}`;
// }),
// ].join(";\n") + ";";
// const availableFields = Object.keys(columns)
// .map((columnKey: string) => `"${columnKey}"`)
// .join("|\n");
// monaco.languages.typescript.javascriptDefaults.addExtraLib(
// ["/**", " * extensions type configuration", " */", extensionsDefs].join(
// "\n"
// ),
// "ts:filename/extensions.d.ts"
// );
// monaco.languages.typescript.javascriptDefaults.addExtraLib(
// [
// "// basic types that are used in all places",
// "declare var require: any;",
// "declare var Buffer: any;",
// "const ref: FirebaseFirestore.DocumentReference;",
// "const storage: firebasestorage.Storage;",
// "const db: FirebaseFirestore.Firestore;",
// "const auth: firebaseauth.BaseAuth;",
// `type Row = {${rowDefinition}};`,
// `type Field = ${availableFields} | string | object;`,
// `type Fields = Field[];`,
// ].join("\n"),
// "ts:filename/rowFields.d.ts"
// );
// };
// TODO:
// Set row definitions
// useEffect(() => {
// if (!monaco || !rowyRun || !tableState?.columns) return;
// try {
// setBaseDefinitions(monaco, tableState.columns);
// } catch (error) {
// console.error("Could not set basic", error);
// }
// // set available secrets from secretManager
// try {
// setSecrets(monaco, rowyRun);
// } catch (error) {
// console.error("Could not set secrets: ", error);
// }
// }, [monaco, tableState?.columns, rowyRun]);
let boxSx: SystemStyleObject<Theme> = {
minWidth: 400,
minHeight,
height: minHeight,
borderRadius: 1,
resize: "vertical",
overflow: "hidden",
position: "relative",
backgroundColor: disabled ? "transparent" : theme.palette.action.input,
"&::after": {
content: '""',
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
pointerEvents: "none",
borderRadius: "inherit",
boxShadow: `0 -1px 0 0 ${theme.palette.text.disabled} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
transition: theme.transitions.create("box-shadow", {
duration: theme.transitions.duration.short,
}),
},
"&:hover::after": {
boxShadow: `0 -1px 0 0 ${theme.palette.text.primary} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
},
"&:focus-within::after": {
boxShadow: `0 -2px 0 0 ${theme.palette.primary.main} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
},
...(error
? {
"&::after, &:hover::after, &:focus-within::after": {
boxShadow: `0 -2px 0 0 ${theme.palette.error.main} inset,
0 0 0 1px ${theme.palette.action.inputOutline} inset`,
},
}
: {}),
"& .editor": {
// Overwrite user-select: none that causes editor
// to not be focusable in Safari
userSelect: "auto",
height: "100%",
},
"& .monaco-editor, & .monaco-editor .margin, & .monaco-editor-background": {
backgroundColor: "transparent",
},
};
if (fullScreen)
boxSx = {
...boxSx,
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: theme.zIndex.tooltip + 1,
m: "0 !important",
resize: "none",
backgroundColor: theme.palette.background.paper,
borderRadius: 0,
"&::after": { display: "none" },
};
return { boxSx };
}

42
src/components/CodeEditor/utils.d.ts vendored Normal file
View File

@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* utility functions
*/
declare namespace RULES_UTILS {
/**
* Gets the secret defined in Google Cloud Secret
*/
async function getSecret(name: string, v?: string): any {}
/**
* Async version of forEach
*/
async function asyncForEach(array: any[], callback: Function): void {}
/**
* Generate random ID from numbers and English characters including lowercase and uppercase
*/
function generateId(): string {}
/**
* Add an item to an array field
*/
function arrayUnion(val: string): void {}
/**
* Remove an item to an array field
*/
function arrayRemove(val: string): void {}
/**
* Increment a number field
*/
function increment(val: number): void {}
function hasRequiredFields(requiredFields: string[], data: any): boolean {}
function hasAnyRole(
authorizedRoles: string[],
context: functions.https.CallableContext
): boolean {}
}

View File

@@ -0,0 +1,41 @@
import ReactMarkdown from "react-markdown";
import type { ReactMarkdownOptions } from "react-markdown/lib/react-markdown";
import remarkGfm from "remark-gfm";
import { Typography, Link } from "@mui/material";
const remarkPlugins = [remarkGfm];
const components: ReactMarkdownOptions["components"] = {
a: (props) => <Link color="inherit" {...props} />,
p: Typography,
// eslint-disable-next-line jsx-a11y/alt-text
img: (props) => (
<img style={{ maxWidth: "100%", borderRadius: 4 }} alt="" {...props} />
),
};
const restrictionPresets = {
singleLine: ["p", "em", "strong", "a", "code", "del"],
};
export interface IRenderedMarkdownProps extends ReactMarkdownOptions {
restrictionPreset?: keyof typeof restrictionPresets;
}
export default function RenderedMarkdown({
restrictionPreset,
...props
}: IRenderedMarkdownProps) {
return (
<ReactMarkdown
{...props}
allowedElements={
restrictionPreset ? restrictionPresets[restrictionPreset] : undefined
}
unwrapDisallowed
linkTarget="_blank"
remarkPlugins={remarkPlugins}
components={{ ...components, ...props.components }}
/>
);
}

View File

@@ -0,0 +1,114 @@
import { useState } from "react";
import {
Stepper,
StepperProps,
Step,
StepProps,
StepButton,
StepButtonProps,
StepLabel,
StepLabelProps,
Typography,
StepContent,
StepContentProps,
} from "@mui/material";
import ExpandIcon from "@mui/icons-material/KeyboardArrowDown";
export interface ISteppedAccordionProps extends Partial<StepperProps> {
steps: {
id: string;
title: React.ReactNode;
subtitle?: React.ReactNode;
optional?: boolean;
content: React.ReactNode;
error?: boolean;
stepProps?: Partial<StepProps>;
labelButtonProps?: Partial<StepButtonProps>;
labelProps?: Partial<StepLabelProps>;
contentProps?: Partial<StepContentProps>;
}[];
disableUnmount?: boolean;
}
export default function SteppedAccordion({
steps,
disableUnmount,
...props
}: ISteppedAccordionProps) {
const [activeStep, setActiveStep] = useState(steps[0].id);
return (
<Stepper
nonLinear
activeStep={steps.findIndex((x) => x.id === activeStep)}
orientation="vertical"
{...props}
sx={{
mt: 0,
"& .MuiStepLabel-root": { width: "100%" },
"& .MuiStepLabel-label": {
display: "flex",
width: "100%",
typography: "subtitle2",
"&.Mui-active": { typography: "subtitle2" },
},
"& .MuiStepLabel-label svg": {
display: "block",
marginLeft: "auto",
my: ((24 - 18) / 2 / 8) * -1,
transition: (theme) => theme.transitions.create("transform"),
},
"& .Mui-active svg": {
transform: "rotate(180deg)",
},
...props.sx,
}}
>
{steps.map(
({
id,
title,
subtitle,
optional,
content,
error,
stepProps,
labelButtonProps,
labelProps,
contentProps,
}) => (
<Step key={id} {...stepProps}>
<StepButton
onClick={() => setActiveStep((s) => (s === id ? "" : id))}
optional={
subtitle ||
(optional && (
<Typography variant="caption">Optional</Typography>
))
}
{...labelButtonProps}
>
<StepLabel error={error} {...labelProps}>
{title}
<ExpandIcon />
</StepLabel>
</StepButton>
<StepContent
TransitionProps={
disableUnmount ? { unmountOnExit: false } : undefined
}
{...contentProps}
>
{content}
</StepContent>
</Step>
)
)}
</Stepper>
);
}

View File

@@ -0,0 +1,61 @@
import { useState } from "react";
import { Control } from "react-hook-form";
import type { UseFormReturn, FieldValues } from "react-hook-form";
import { IconButton, Menu } from "@mui/material";
import ExportIcon from "assets/icons/Export";
import ImportIcon from "assets/icons/Import";
import ImportSettings from "./ImportSettings";
import ExportSettings from "./ExportSettings";
import { TableSettingsDialogState } from "@src/atoms/globalScope";
export interface IActionsMenuProps {
mode: TableSettingsDialogState["mode"];
control: Control;
useFormMethods: UseFormReturn<FieldValues, object>;
}
export default function ActionsMenu({
mode,
control,
useFormMethods,
}: IActionsMenuProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClose = () => setAnchorEl(null);
return (
<>
<IconButton
aria-label="Actions…"
id="table-settings-actions-button"
aria-controls="table-settings-actions-menu"
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
{mode === "create" ? <ImportIcon /> : <ExportIcon />}
</IconButton>
<Menu
id="table-settings-actions-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{ "aria-labelledby": "table-settings-actions-button" }}
disablePortal
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<ImportSettings
closeMenu={handleClose}
control={control}
useFormMethods={useFormMethods}
/>
<ExportSettings closeMenu={handleClose} control={control} />
</Menu>
</>
);
}

View File

@@ -0,0 +1,104 @@
import { useState } from "react";
import { Control, useWatch } from "react-hook-form";
import stringify from "json-stable-stringify-without-jsonify";
import { isEmpty } from "lodash-es";
import { useSnackbar } from "notistack";
import { MenuItem, DialogContentText, LinearProgress } from "@mui/material";
import { analytics, logEvent } from "@src/analytics";
import Modal from "@src/components/Modal";
import CodeEditor from "@src/components/CodeEditor";
export interface IExportSettingsProps {
closeMenu: () => void;
control: Control;
}
export default function ExportSettings({
closeMenu,
control,
}: IExportSettingsProps) {
const [open, setOpen] = useState(false);
const { _suggestedRules, ...values } = useWatch({ control });
// TODO:
const tableConfigState = {} as any;
// const [tableConfigState] = useTableConfig(values.id);
const { id, ref, ..._schema } = tableConfigState.doc ?? {};
const formattedJson = stringify(
// Allow values._schema to take priority if user imported _schema before
"_schema" in values || isEmpty(_schema) ? values : { ...values, _schema },
{
space: 2,
// TODO: types
cmp: (a: any, b: any) =>
// Sort _schema at the end
a.key.startsWith("_")
? 1
: // Otherwise, sort alphabetically
a.key > b.key
? 1
: -1,
}
);
const handleClose = () => {
setOpen(false);
closeMenu();
};
const { enqueueSnackbar } = useSnackbar();
const handleExport = () => {
logEvent(analytics, "export_tableSettings");
navigator.clipboard.writeText(formattedJson);
enqueueSnackbar("Copied to clipboard");
handleClose();
};
return (
<>
<MenuItem onClick={() => setOpen(true)}>Export table settings</MenuItem>
{open && (
<Modal
onClose={handleClose}
title="Export table settings"
header={
<>
{tableConfigState.loading && values.id && (
<LinearProgress
style={{ position: "absolute", top: 0, left: 0, right: 0 }}
/>
)}
<DialogContentText style={{ margin: "0 var(--dialog-spacing)" }}>
Export table settings and columns in JSON format
</DialogContentText>
</>
}
body={
<div style={{ marginTop: "var(--dialog-contents-spacing)" }}>
<CodeEditor
disabled
value={formattedJson}
defaultLanguage="json"
minHeight={300}
/>
</div>
}
actions={{
primary: {
children: "Copy to clipboard",
onClick: handleExport,
},
secondary: {
children: "Cancel",
onClick: handleClose,
},
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,182 @@
import { useState } from "react";
import { useSetAtom } from "jotai";
import { Control, useWatch } from "react-hook-form";
import type { UseFormReturn, FieldValues } from "react-hook-form";
import stringify from "json-stable-stringify-without-jsonify";
import { isEmpty, get } from "lodash-es";
import { useSnackbar } from "notistack";
import { MenuItem, DialogContentText, FormHelperText } from "@mui/material";
import Modal from "@src/components/Modal";
import DiffEditor from "@src/components/CodeEditor/DiffEditor";
// import useTableConfig from "@src/hooks/useTable/useTableConfig";
import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope";
import { analytics, logEvent } from "@src/analytics";
export interface IImportSettingsProps {
closeMenu: () => void;
control: Control;
useFormMethods: UseFormReturn<FieldValues, object>;
}
export default function ImportSettings({
closeMenu,
control,
useFormMethods,
}: IImportSettingsProps) {
const [open, setOpen] = useState(false);
const [newSettings, setNewSettings] = useState("");
const [valid, setValid] = useState(true);
const { _suggestedRules, ...values } = useWatch({ control });
// TODO:
const tableConfigState = {} as any;
// const [tableConfigState] = useTableConfig(values.id);
const { id, ref, ..._schema } = tableConfigState.doc ?? {};
const formattedJson = stringify(
// Allow values._schema to take priority if user imported _schema before
"_schema" in values || isEmpty(_schema) ? values : { ...values, _schema },
{
space: 2,
// TODO: types
cmp: (a: any, b: any) =>
// Sort _schema at the end
a.key.startsWith("_")
? 1
: // Otherwise, sort alphabetically
a.key > b.key
? 1
: -1,
}
);
const handleClose = () => {
setOpen(false);
closeMenu();
};
const confirm = useSetAtom(confirmDialogAtom, globalScope);
const { enqueueSnackbar } = useSnackbar();
const { setValue } = useFormMethods;
const handleImport = () => {
logEvent(analytics, "import_tableSettings");
const { id, collection, ...newValues } = JSON.parse(newSettings);
for (const key in newValues) {
setValue(key, newValues[key], {
shouldDirty: true,
shouldValidate: true,
});
}
enqueueSnackbar("Imported settings");
handleClose();
};
return (
<>
<MenuItem onClick={() => setOpen(true)}>Import table settings</MenuItem>
{open && (
<Modal
onClose={handleClose}
title="Import table settings"
header={
<DialogContentText style={{ margin: "0 var(--dialog-spacing)" }}>
Import table settings in JSON format. This will overwrite any
existing settings, except for the table ID and collection.
</DialogContentText>
}
body={
<div style={{ marginTop: "var(--dialog-contents-spacing)" }}>
<DiffEditor
original={formattedJson}
modified={newSettings}
language="json"
onChange={(v) => {
try {
if (v) {
JSON.parse(v);
setNewSettings(v);
setValid(true);
}
} catch (e) {
console.log(`Failed to parse JSON: ${e}`);
setValid(false);
}
}}
error={!valid}
minHeight={300}
/>
</div>
}
footer={
!valid && (
<FormHelperText
error
variant="filled"
sx={{ mx: "auto", mt: 1, mb: -1 }}
>
Invalid JSON
</FormHelperText>
)
}
actions={{
primary: {
children: "Import",
onClick: () => {
const parsedJson = JSON.parse(newSettings);
const hasExtensions = Boolean(
get(parsedJson, "_schema.extensionObjects")
);
const hasWebhooks = Boolean(
get(parsedJson, "_schema.webhooks")
);
confirm({
title: "Import settings?",
body: (
<>
<DialogContentText paragraph>
You will overwrite any existing settings for this table,{" "}
<b>except for the table ID and collection</b>.
</DialogContentText>
{(hasExtensions || hasWebhooks) && (
<DialogContentText paragraph>
Youre importing new{" "}
<b>
{[
hasExtensions && "extensions",
hasWebhooks && "webhooks",
]
.filter(Boolean)
.join(" and ")}
</b>{" "}
for this table. Youll be prompted to <b>deploy</b>{" "}
them when you save the table settings.
</DialogContentText>
)}
</>
),
confirm: "Import",
handleConfirm: handleImport,
});
},
disabled: !valid,
},
secondary: {
children: "Cancel",
onClick: handleClose,
},
}}
maxWidth="lg"
/>
)}
</>
);
}

View File

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

View File

@@ -0,0 +1,143 @@
import { useState } from "react";
import { useSetAtom } from "jotai";
import { useNavigate } from "react-router-dom";
import { useSnackbar } from "notistack";
import { IconButton, Menu, MenuItem, DialogContentText } from "@mui/material";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import {
globalScope,
confirmDialogAtom,
TableSettings,
} from "@src/atoms/globalScope";
import { ROUTES } from "@src/constants/routes";
import { analytics, logEvent } from "@src/analytics";
export interface IDeleteMenuProps {
clearDialog: () => void;
data: TableSettings | null;
}
export default function DeleteMenu({ clearDialog, data }: IDeleteMenuProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClose = () => setAnchorEl(null);
const navigate = useNavigate();
const confirm = useSetAtom(confirmDialogAtom, globalScope);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const handleResetStructure = async () => {
const snack = enqueueSnackbar("Resetting columns…", { persist: true });
// TODO:
// const schemaDocRef = db.doc(`${TABLE_SCHEMAS}/${data!.id}`);
// await schemaDocRef.update({ columns: {} });
clearDialog();
closeSnackbar(snack);
};
const handleDelete = async () => {
const snack = enqueueSnackbar("Deleting table…", { persist: true });
// TODO:
// const tablesDocRef = db.doc(SETTINGS);
// const tableData = (await tablesDocRef.get()).data();
// const updatedTables = tableData?.tables.filter(
// (table) => table.id !== data?.id || table.tableType !== data?.tableType
// );
// tablesDocRef.update({ tables: updatedTables });
// await db
// .collection(
// data?.tableType === "primaryCollection"
// ? TABLE_SCHEMAS
// : TABLE_GROUP_SCHEMAS
// )
// .doc(data?.id)
// .delete();
logEvent(analytics, "delete_table");
clearDialog();
closeSnackbar(snack);
navigate(ROUTES.home);
};
return (
<>
<IconButton
aria-label="Delete table…"
id="table-settings-delete-button"
aria-controls="table-settings-delete-menu"
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
<DeleteIcon />
</IconButton>
<Menu
id="table-settings-delete-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{ "aria-labelledby": "table-settings-delete-button" }}
disablePortal
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<MenuItem
color="error"
onClick={() =>
confirm({
title: `Reset columns of “${data?.name}”?`,
body: (
<>
<DialogContentText paragraph>
This will only reset the columns of this column so you can
set up the columns again.
</DialogContentText>
<DialogContentText>
You will not lose any data in your Firestore collection{" "}
<code>{data?.collection}</code>.
</DialogContentText>
</>
),
confirm: "Reset",
confirmColor: "error",
handleConfirm: handleResetStructure,
})
}
>
Reset columns
</MenuItem>
<MenuItem
color="error"
onClick={() =>
confirm({
title: `Delete the table “${data?.name}”?`,
body: (
<>
<DialogContentText paragraph>
This will only delete the Rowy configuration data.
</DialogContentText>
<DialogContentText>
You will not lose any data in your Firestore collection{" "}
<code>{data?.collection}</code>.
</DialogContentText>
</>
),
confirm: "Delete",
confirmColor: "error",
handleConfirm: handleDelete,
})
}
>
Delete table
</MenuItem>
</Menu>
</>
);
}

View File

@@ -0,0 +1,148 @@
import { useState } from "react";
import { useAtom } from "jotai";
import { useWatch } from "react-hook-form";
import {
InputLabel,
Collapse,
FormControlLabel,
Checkbox,
Grid,
Button,
} from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { IFieldComponentProps } from "@rowy/form-builder";
import { globalScope, projectIdAtom } from "@src/atoms/globalScope";
type customizationOptions = "allRead" | "authRead" | "subcollections" | "user";
export interface ISuggestedRulesProps extends IFieldComponentProps {}
export default function SuggestedRules({
useFormMethods: { control },
label,
}: ISuggestedRulesProps) {
const [projectId] = useAtom(projectIdAtom, globalScope);
const watched = useWatch({
control,
name: ["collection", "roles", "readOnly"],
} as any);
const [collection, roles, readOnly] = Array.isArray(watched) ? watched : [];
const [customized, setCustomized] = useState<boolean>(false);
const [customizations, setCustomizations] = useState<customizationOptions[]>(
[]
);
const handleChange =
(option: customizationOptions) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
setCustomizations((prev) => {
const set = new Set(prev || []);
if (e.target.checked) set.add(option);
else set.delete(option);
return Array.from(set);
});
const generatedRules = `match /${collection}/{${
customizations.includes("subcollections") ? "document=**" : "docId"
}} {
allow read, write: if hasAnyRole(${
readOnly ? `["ADMIN"]` : JSON.stringify(roles)
});${
customizations.includes("allRead")
? "\n allow read: if true;"
: customizations.includes("authRead")
? "\n allow read: if request.auth != null;"
: ""
}${
customizations.includes("user")
? `\n
allow create: if request.auth != null;
allow get, update, delete: if isDocOwner(userId);`
: ""
}
}`;
return (
<>
<InputLabel sx={{ mb: 0.5, ml: 0.25 }}>{label}</InputLabel>
<pre style={{ margin: 0, userSelect: "all", whiteSpace: "pre-wrap" }}>
{generatedRules}
</pre>
<Collapse in={customized}>
<Grid container>
<Grid item xs={12} sm={6}>
<FormControlLabel
control={
<Checkbox
checked={customizations.includes("allRead")}
onChange={handleChange("allRead")}
/>
}
label="Anyone can read"
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControlLabel
control={
<Checkbox
checked={customizations.includes("authRead")}
onChange={handleChange("authRead")}
/>
}
label="All signed-in users can read"
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControlLabel
control={
<Checkbox
checked={customizations.includes("user")}
onChange={handleChange("user")}
/>
}
label="Users can create and edit docs"
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControlLabel
control={
<Checkbox
checked={customizations.includes("subcollections")}
onChange={handleChange("subcollections")}
/>
}
label="Same rules for all subcollections"
/>
</Grid>
</Grid>
</Collapse>
<Grid container spacing={1} style={{ marginTop: 0 }}>
{!customized && (
<Grid item>
<Button onClick={() => setCustomized(true)}>Customize</Button>
</Grid>
)}
<Grid item>
<Button onClick={() => navigator.clipboard.writeText(generatedRules)}>
Copy to clipboard
</Button>
</Grid>
<Grid item>
<Button
href={`https://console.firebase.google.com/u/0/project/${projectId}/firestore/rules`}
target="_blank"
rel="noopener noreferrer"
>
Set rules in Firebase Console
<InlineOpenInNewIcon />
</Button>
</Grid>
</Grid>
</>
);
}

View File

@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { useWatch } from "react-hook-form";
import { camelCase } from "lodash-es";
import {
ShortTextComponent,
IShortTextComponentProps,
} from "@rowy/form-builder";
export interface ITableIdProps extends IShortTextComponentProps {
watchedField?: string;
}
export default function TableId({ watchedField, ...props }: ITableIdProps) {
const {
field: { onChange },
useFormMethods: { control },
disabled,
} = props;
const watchedValue = useWatch({ control, name: watchedField } as any);
useEffect(() => {
if (!disabled && typeof watchedValue === "string" && !!watchedValue)
onChange(camelCase(watchedValue));
}, [watchedValue, disabled]);
return (
<ShortTextComponent
{...props}
sx={{ "& .MuiInputBase-input": { fontFamily: "mono" } }}
/>
);
}

View File

@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useWatch } from "react-hook-form";
import { startCase } from "lodash-es";
import {
ShortTextComponent,
IShortTextComponentProps,
} from "@rowy/form-builder";
export interface ITableNameProps extends IShortTextComponentProps {
watchedField?: string;
}
export default function TableName({ watchedField, ...props }: ITableNameProps) {
const {
field: { onChange },
useFormMethods: { control },
disabled,
} = props;
const watchedValue = useWatch({ control, name: watchedField } as any);
useEffect(() => {
if (!disabled && typeof watchedValue === "string" && !!watchedValue)
onChange(startCase(watchedValue));
}, [watchedValue, disabled]);
return <ShortTextComponent {...props} />;
}

View File

@@ -0,0 +1,461 @@
import { useAtom, useSetAtom } from "jotai";
import useSWR from "swr";
import { useSnackbar } from "notistack";
import { useLocation, useNavigate } from "react-router-dom";
import { find, sortBy, get, isEmpty } from "lodash-es";
import { FieldValues } from "react-hook-form";
import { DialogContentText, Stack, Typography } from "@mui/material";
import { FormDialog, FormFields } from "@rowy/form-builder";
import { tableSettings } from "./form";
import TableName from "./TableName";
import TableId from "./TableId";
import SuggestedRules from "./SuggestedRules";
import SteppedAccordion from "@src/components/SteppedAccordion";
import ActionsMenu from "./ActionsMenu";
import DeleteMenu from "./DeleteMenu";
import {
globalScope,
tableSettingsDialogAtom,
tablesAtom,
rolesAtom,
rowyRunAtom,
confirmDialogAtom,
TableSettings,
} from "@src/atoms/globalScope";
import { analytics, logEvent } from "@src/analytics";
// TODO:
// import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import { runRoutes } from "@src/constants/runRoutes";
import {
CONFIG,
TABLE_GROUP_SCHEMAS,
TABLE_SCHEMAS,
} from "@src/config/dbPaths";
import { Controller } from "react-hook-form";
import { ROUTES } from "@src/constants/routes";
const customComponents = {
tableName: {
component: TableName,
defaultValue: "",
validation: [["string"]],
},
tableId: {
component: TableId,
defaultValue: "",
validation: [["string"]],
},
suggestedRules: {
component: SuggestedRules,
defaultValue: "",
validation: [["string"]],
},
};
export default function TableSettingsDialog() {
const [{ open, mode, data }, setTableSettingsDialog] = useAtom(
tableSettingsDialogAtom,
globalScope
);
const clearDialog = () => setTableSettingsDialog({ open: false });
const [roles] = useAtom(rolesAtom, globalScope);
const [tables] = useAtom(tablesAtom, globalScope);
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
const location = useLocation();
const navigate = useNavigate();
const confirm = useSetAtom(confirmDialogAtom, globalScope);
// const snackLogContext = useSnackLogContext();
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const sectionNames = Array.from(
new Set((tables ?? []).map((t) => t.section))
);
const { data: collections } = useSWR(
"firebaseCollections",
() => rowyRun({ route: runRoutes.listCollections }),
{
revalidateOnMount: true,
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 60_000 * 60,
}
);
if (!open) return null;
// TODO: types
const handleSubmit = async (v: FieldValues) => {
const { _suggestedRules, ...values } = v;
const data = { ...values };
if (values.schemaSource)
data.schemaSource = find(tables, { id: values.schemaSource });
const hasExtensions = !isEmpty(get(data, "_schema.extensionObjects"));
const hasWebhooks = !isEmpty(get(data, "_schema.webhooks"));
const deployExtensionsWebhooks = (onComplete?: () => void) => {
if (rowyRun && (hasExtensions || hasWebhooks)) {
confirm({
title: `Deploy ${[
hasExtensions && "extensions",
hasWebhooks && "webhooks",
]
.filter(Boolean)
.join(" and ")}?`,
body: "You can also deploy later from the table page",
confirm: "Deploy",
cancel: "Later",
handleConfirm: async () => {
const tablePath = data.collection;
const tableConfigPath = `${
data.tableType !== "collectionGroup"
? TABLE_SCHEMAS
: TABLE_GROUP_SCHEMAS
}/${data.id}`;
if (hasExtensions) {
// find derivative, default value
// TODO:
// snackLogContext.requestSnackLog();
rowyRun({
route: runRoutes.buildFunction,
body: {
tablePath,
pathname: `/${
data.tableType === "collectionGroup"
? "tableGroup"
: "table"
}/${data.id}`,
tableConfigPath,
},
});
logEvent(analytics, "deployed_extensions");
}
if (hasWebhooks) {
const resp = await rowyRun({
service: "hooks",
route: runRoutes.publishWebhooks,
body: {
tableConfigPath,
tablePath,
},
});
enqueueSnackbar(resp.message, {
variant: resp.success ? "success" : "error",
});
logEvent(analytics, "published_webhooks");
}
if (onComplete) onComplete();
},
handleCancel: async () => {
let _schema: Record<string, any> = {};
if (hasExtensions) {
_schema.extensionObjects = get(
data,
"_schema.extensionObjects"
// TODO: types
)!.map((x: any) => ({
...x,
active: false,
}));
}
if (hasWebhooks) {
// TODO: types
_schema.webhooks = get(data, "_schema.webhooks")!.map(
(x: any) => ({
...x,
active: false,
})
);
}
// TODO:
// await settingsActions?.updateTable({
// id: data.id,
// tableType: data.tableType,
// _schema,
// });
if (onComplete) onComplete();
},
});
} else {
if (onComplete) onComplete();
}
};
if (mode === "update") {
// TODO:
// await settingsActions?.updateTable(data);
deployExtensionsWebhooks();
clearDialog();
logEvent(analytics, "update_table", { type: values.tableType });
enqueueSnackbar("Updated table");
} else {
const creatingSnackbar = enqueueSnackbar("Creating table…", {
persist: true,
});
// TODO:
// await settingsActions?.createTable(data);
await logEvent(analytics, "create_table", { type: values.tableType });
deployExtensionsWebhooks(() => {
if (location.pathname === ROUTES.tables) {
navigate(
`${
values.tableType === "collectionGroup"
? ROUTES.tableGroup
: ROUTES.table
}/${values.id}`
);
} else {
navigate(values.id);
}
clearDialog();
closeSnackbar(creatingSnackbar);
});
}
};
const fields = tableSettings(
mode,
roles,
sectionNames,
sortBy(
tables?.map((table) => ({
label: table.name,
value: table.id,
section: table.section,
collection: table.collection,
})),
["section", "label"]
),
Array.isArray(collections) ? collections.filter((x) => x !== CONFIG) : null
);
return (
<FormDialog
onClose={clearDialog}
title={
(mode === "create" ? "Create table" : "Table settings") +
" (INCOMPLETE)"
}
fields={fields}
customBody={(formFieldsProps) => {
const { errors } = formFieldsProps.useFormMethods.formState;
const groupedErrors: Record<string, string> = Object.entries(
errors
).reduce((acc, [name, err]) => {
const match = find(fields, ["name", name])?.step;
if (!match) return acc;
acc[match] = err.message;
return acc;
}, {} as Record<string, string>);
return (
<>
<Controller
control={formFieldsProps.control}
name="_schema"
defaultValue={{}}
render={() => <></>}
/>
<Stack
direction="row"
spacing={1}
sx={{
display: "flex",
height: "var(--dialog-title-height)",
alignItems: "center",
position: "absolute",
top: 0,
right: 40 + 12 + 8,
}}
>
<ActionsMenu
mode={mode}
control={formFieldsProps.control}
useFormMethods={formFieldsProps.useFormMethods}
/>
{mode === "update" && (
<DeleteMenu clearDialog={clearDialog} data={data} />
)}
</Stack>
<SteppedAccordion
disableUnmount
steps={
[
{
id: "collection",
title: "Collection",
content: (
<>
<DialogContentText paragraph>
Connect this table to a new or existing Firestore
collection
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter((f) => f.step === "collection")}
/>
</>
),
optional: false,
error: Boolean(groupedErrors.collection),
subtitle: groupedErrors.collection && (
<Typography variant="caption" color="error">
{groupedErrors.collection}
</Typography>
),
},
{
id: "display",
title: "Display",
content: (
<>
<DialogContentText paragraph>
Set how this table is displayed to users
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter((f) => f.step === "display")}
customComponents={customComponents}
/>
</>
),
optional: false,
error: Boolean(groupedErrors.display),
subtitle: groupedErrors.display && (
<Typography variant="caption" color="error">
{groupedErrors.display}
</Typography>
),
},
{
id: "accessControls",
title: "Access controls",
content: (
<>
<DialogContentText paragraph>
Set who can view and edit this table. Only ADMIN users
can edit table settings or add, edit, and delete
columns.
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter(
(f) => f.step === "accessControls"
)}
customComponents={customComponents}
/>
</>
),
optional: false,
error: Boolean(groupedErrors.accessControls),
subtitle: groupedErrors.accessControls && (
<Typography variant="caption" color="error">
{groupedErrors.accessControls}
</Typography>
),
},
{
id: "auditing",
title: "Auditing",
content: (
<>
<DialogContentText paragraph>
Track when users create or update rows
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter((f) => f.step === "auditing")}
/>
</>
),
optional: true,
error: Boolean(groupedErrors.auditing),
subtitle: groupedErrors.auditing && (
<Typography variant="caption" color="error">
{groupedErrors.auditing}
</Typography>
),
},
/*
* TODO: Figure out where to store this settings
{
id: "function",
title: "Cloud Function",
content: (
<>
<DialogContentText paragraph>
Configure cloud function settings, this setting is shared across all tables connected to the same collection
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter((f) => f.step === "function")}
/>
</>
),
optional: true,
error: Boolean(groupedErrors.function),
subtitle: groupedErrors.auditing && (
<Typography variant="caption" color="error">
{groupedErrors.function}
</Typography>
),
},
*/
mode === "create"
? {
id: "columns",
title: "Columns",
content: (
<>
<DialogContentText paragraph>
Initialize table with columns
</DialogContentText>
<FormFields
{...formFieldsProps}
fields={fields.filter(
(f) => f.step === "columns"
)}
/>
</>
),
optional: true,
error: Boolean(groupedErrors.columns),
subtitle: groupedErrors.columns && (
<Typography variant="caption" color="error">
{groupedErrors.columns}
</Typography>
),
}
: null,
].filter(Boolean) as any
}
/>
</>
);
}}
customComponents={customComponents}
values={{ ...data }}
onSubmit={handleSubmit}
SubmitButtonProps={{
children: mode === "create" ? "Create" : "Update",
// TODO:
disabled: true,
}}
/>
);
}

View File

@@ -0,0 +1,512 @@
import { find } from "lodash-es";
import { Field, FieldType } from "@rowy/form-builder";
import { TableSettingsDialogState } from "@src/atoms/globalScope";
import { Link, ListItemText, Typography } from "@mui/material";
import OpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import WarningIcon from "@mui/icons-material/WarningAmber";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import { FieldType as TableFieldType } from "@src/constants/fields";
export const tableSettings = (
mode: TableSettingsDialogState["mode"],
roles: string[] | undefined,
sections: string[] | undefined,
tables:
| { label: string; value: any; section: string; collection: string }[]
| undefined,
collections: string[] | null
): Field[] =>
[
// Step 1: Collection
{
step: "collection",
type: FieldType.singleSelect,
name: "tableType",
label: "Table type",
defaultValue: "primaryCollection",
options: [
{
label: (
<ListItemText
primary="Primary collection"
secondary={
<>
Connect this table to the <b>single collection</b> matching
the collection name entered below
</>
}
style={{ maxWidth: 470 }}
/>
),
value: "primaryCollection",
},
{
label: (
<ListItemText
primary="Collection group"
secondary={
<>
Connect this table to{" "}
<b>all collections and subcollections</b> matching the
collection name entered below
</>
}
style={{ maxWidth: 470 }}
/>
),
value: "collectionGroup",
},
],
required: true,
disabled: mode === "update",
assistiveText: (
<>
Cannot be edited
{mode === "create" && " later"}.{" "}
<Link
href="https://firebase.googleblog.com/2019/06/understanding-collection-group-queries.html"
target="_blank"
rel="noopener noreferrer"
>
Learn more about collection groups
<OpenInNewIcon />
</Link>
</>
),
},
Array.isArray(collections)
? {
step: "collection",
type: FieldType.singleSelect,
name: "collection",
label: "Collection",
labelPlural: "collections",
options: collections,
itemRenderer: (option: any) => (
<code key={option.value}>{option.label}</code>
),
freeText: true,
required: true,
assistiveText: (
<>
{mode === "update" ? (
<>
<WarningIcon
color="warning"
aria-label="Warning"
sx={{ fontSize: 16, mr: 0.5, verticalAlign: "middle" }}
/>
You can change which Firestore collection to display. Data in
the new collection must be compatible with the existing
columns.
</>
) : (
"Choose which Firestore collection to display."
)}{" "}
<Link
href={`https://console.firebase.google.com/project/_/firestore/data`}
target="_blank"
rel="noopener noreferrer"
>
Your collections
<OpenInNewIcon />
</Link>
</>
),
AddButtonProps: {
children: "Create collection or use custom path…",
},
AddDialogProps: {
title: "Create collection or use custom path",
textFieldLabel: (
<>
Collection name
<Typography variant="caption" display="block">
If this collection does not exist, it wont be created until
you add a row to the table
</Typography>
</>
),
},
TextFieldProps: {
sx: { "& .MuiInputBase-input": { fontFamily: "mono" } },
},
// https://firebase.google.com/docs/firestore/quotas#collections_documents_and_fields
validation: [
["matches", /^[^\s]+$/, "Collection name cannot have spaces"],
["notOneOf", [".", ".."], "Collection name cannot be . or .."],
[
"test",
"double-underscore",
"Collection name cannot begin and end with __",
(value: any) => !value.startsWith("__") && !value.endsWith("__"),
],
],
}
: {
step: "collection",
type: FieldType.shortText,
name: "collection",
label: "Collection name",
required: true,
assistiveText: (
<>
{mode === "update" ? (
<>
<WarningIcon
color="warning"
aria-label="Warning"
sx={{ fontSize: 16, mr: 0.5, verticalAlign: "middle" }}
/>
You can change which Firestore collection to display. Data in
the new collection must be compatible with the existing
columns.
</>
) : (
"Type the name of the Firestore collection to display."
)}{" "}
<Link
href={`https://console.firebase.google.com/project/_/firestore/data`}
target="_blank"
rel="noopener noreferrer"
>
Your collections
<OpenInNewIcon />
</Link>
</>
),
sx: { "& .MuiInputBase-input": { fontFamily: "mono" } },
// https://firebase.google.com/docs/firestore/quotas#collections_documents_and_fields
validation: [
["matches", /^[^\s]+$/, "Collection name cannot have spaces"],
["notOneOf", [".", ".."], "Collection name cannot be . or .."],
[
"test",
"double-underscore",
"Collection name cannot begin and end with __",
(value: any) => !value.startsWith("__") && !value.endsWith("__"),
],
],
},
// Step 2: Display
{
step: "display",
type: "tableName",
name: "name",
label: "Table name",
required: true,
watchedField: "collection",
assistiveText: "User-facing name for this table",
autoFocus: true,
gridCols: { xs: 12, sm: 6 },
},
{
step: "display",
type: "tableId",
name: "id",
label: "Table ID",
required: true,
watchedField: "name",
assistiveText: `Unique ID used to store this tables configuration. Cannot be edited${
mode === "create" ? " later" : ""
}.`,
disabled: mode === "update",
gridCols: { xs: 12, sm: 6 },
validation:
mode === "create"
? [
[
"test",
"unique",
"Another table exists with this ID",
(value: any) => !find(tables, ["value", value]),
],
]
: [],
},
{
step: "display",
type: FieldType.singleSelect,
name: "section",
label: "Section (optional)",
labelPlural: "sections",
freeText: true,
options: sections,
required: false,
},
{
step: "display",
type: FieldType.paragraph,
name: "description",
label: "Description (optional)",
minRows: 2,
},
// Step 3: Access controls
{
step: "accessControls",
type: FieldType.multiSelect,
name: "roles",
label: "Accessed by",
labelPlural: "roles",
options: roles ?? [],
defaultValue: ["ADMIN"],
required: true,
freeText: true,
},
{
step: "accessControls",
type: FieldType.checkbox,
name: "readOnly",
label: "Read-only for non-ADMIN users",
assistiveText:
"Disable all editing functionality. Locks all columns and disables adding and deleting rows and columns.",
defaultValue: false,
},
{
step: "accessControls",
type: FieldType.contentParagraph,
name: "_contentParagraph_rules",
label: (
<>
To enable access controls for this table, you must set the
corresponding Firestore Security Rules.{" "}
<Link
href={WIKI_LINKS.setupRoles + "#table-rules"}
target="_blank"
rel="noopener noreferrer"
style={{ position: "relative", zIndex: 1 }}
>
Learn how to write rules
<OpenInNewIcon />
</Link>
</>
),
},
{
step: "accessControls",
type: "suggestedRules",
name: "_suggestedRules",
label: "Suggested Firestore Rules",
watchedField: "collection",
},
// Step 4: Auditing
{
step: "auditing",
type: FieldType.checkbox,
name: "audit",
label: "Enable auditing for this table",
defaultValue: true,
},
{
step: "auditing",
type: FieldType.shortText,
name: "auditFieldCreatedBy",
label: "Created By field key (optional)",
defaultValue: "_createdBy",
displayCondition: "return values.audit",
assistiveText: "Optionally, change the field key",
gridCols: { xs: 12, sm: 6 },
sx: { "& .MuiInputBase-input": { fontFamily: "mono" } },
},
{
step: "auditing",
type: FieldType.shortText,
name: "auditFieldUpdatedBy",
label: "Updated By field key (optional)",
defaultValue: "_updatedBy",
displayCondition: "return values.audit",
assistiveText: "Optionally, change the field key",
gridCols: { xs: 12, sm: 6 },
sx: { "& .MuiInputBase-input": { fontFamily: "mono" } },
},
// Step 5:Cloud functions
/*
{
step: "function",
type: FieldType.slider,
name: "triggerDepth",
defaultValue: 1,
min: 1,
max: 5,
label: "Collection depth",
displayCondition: "return values.tableType === 'collectionGroup'",
assistiveText: (
<>
{name} Cloud Functions that rely on{" "}
<Link
href="https://firebase.google.com/docs/functions/firestore-events#function_triggers"
target="_blank"
rel="noopener noreferrer"
>
Firestore triggers
</Link>{" "}
on this table require you to manually set the depth of this collection
group.
<br />
<Link
href="https://stackoverflow.com/questions/58186741/watch-a-collectiongroup-with-firestore-using-cloud-functions"
target="_blank"
rel="noopener noreferrer"
>
Learn more about this requirement
<OpenInNewIcon />
</Link>
</>
),
},
{
step: "function",
type: FieldType.singleSelect,
name: "function.memory",
label: "Memory Allocation",
defaultValue: "256MB",
options: ["128MB", "256MB", "512MB", "1GB", "2GB", "4GB", "8GB"],
required: true,
gridCols: { xs: 12, sm: 6 },
},
{
step: "function",
type: FieldType.shortText,
name: "function.timeout",
label: "Timeout",
defaultValue: 60,
InputProps: {
type: "number",
endAdornment: <InputAdornment position="end">seconds</InputAdornment>,
},
gridCols: { xs: 12, sm: 6 },
},
{
step: "function",
type: FieldType.contentSubHeader,
name: "functionHeader",
label: "Auto scaling",
assistiveText: (
<>
<Link
href="https://firebase.google.com/docs/functions/autoscaling"
target="_blank"
rel="noopener noreferrer"
>
Learn more about auto scaling
<OpenInNewIcon />
</Link>
</>
),
},
{
step: "function",
type: FieldType.shortText,
name: "function.minInstances",
label: "Minimum Instances",
defaultValue: 0,
InputProps: {
type: "number",
},
gridCols: { xs: 12, sm: 6 },
},
{
step: "function",
type: FieldType.shortText,
name: "function.maxInstances",
label: "Maximum Instances",
defaultValue: 1000,
InputProps: {
type: "number",
},
gridCols: { xs: 12, sm: 6 },
},
*/
mode === "create" && tables && tables?.length !== 0
? {
step: "columns",
type: FieldType.singleSelect,
name: "schemaSource",
label: "Copy columns from existing table (optional)",
labelPlural: "tables",
options: tables,
clearable: true,
freeText: false,
itemRenderer: (option: {
value: string;
label: string;
section: string;
collection: string;
}) => (
<>
{option.section} &gt; {option.label}{" "}
<code style={{ marginLeft: "auto" }}>{option.collection}</code>
</>
),
}
: null,
mode === "create"
? {
step: "columns",
type: FieldType.contentSubHeader,
name: "_contentSubHeader_initialColumns",
label: "Initial columns",
sx: { "&&": { mb: 1 }, typography: "button", ml: 2 / 8 },
}
: null,
mode === "create"
? {
step: "columns",
type: FieldType.checkbox,
name: `_initialColumns.${TableFieldType.createdBy}`,
label: "Created By",
displayCondition: "return values.audit",
gridCols: 6,
disablePaddingTop: true,
}
: null,
mode === "create"
? {
step: "columns",
type: FieldType.checkbox,
name: `_initialColumns.${TableFieldType.updatedBy}`,
label: "Updated By",
displayCondition: "return values.audit",
gridCols: 6,
disablePaddingTop: true,
}
: null,
mode === "create"
? {
step: "columns",
type: FieldType.checkbox,
name: `_initialColumns.${TableFieldType.createdAt}`,
label: "Created At",
displayCondition: "return values.audit",
gridCols: 6,
disablePaddingTop: true,
}
: null,
mode === "create"
? {
step: "columns",
type: FieldType.checkbox,
name: `_initialColumns.${TableFieldType.updatedAt}`,
label: "Updated At",
displayCondition: "return values.audit",
gridCols: 6,
disablePaddingTop: true,
}
: null,
mode === "create"
? {
step: "columns",
type: FieldType.checkbox,
name: `_initialColumns.${TableFieldType.id}`,
label: "Row ID",
disablePaddingTop: true,
}
: null,
].filter((field) => field !== null) as Field[];

View File

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

View File

@@ -0,0 +1,33 @@
import { Zoom, Stack, Typography } from "@mui/material";
export default function HomeWelcomePrompt() {
return (
<Zoom in style={{ transformOrigin: `${320 - 52}px ${320 - 52}px` }}>
<Stack
justifyContent="center"
sx={{
bgcolor: "primary.main",
color: "primary.contrastText",
boxShadow: 24,
width: 320,
height: 320,
p: 5,
borderRadius: "50% 50% 0 50%",
position: "fixed",
bottom: 0,
right: 0,
}}
>
<Typography variant="overline" component="h1" gutterBottom>
Get started
</Typography>
<Typography variant="h5" component="p">
Create a table from a new or existing Firestore collection
</Typography>
</Stack>
</Zoom>
);
}

View File

@@ -0,0 +1,79 @@
import { Link } from "react-router-dom";
import {
Card,
CardActionArea,
CardContent,
Typography,
CardActions,
Button,
} from "@mui/material";
import GoIcon from "@src/assets/icons/Go";
import RenderedMarkdown from "@src/components/RenderedMarkdown";
import { TableSettings } from "@src/atoms/globalScope";
export interface ITableCardProps extends TableSettings {
link: string;
actions?: React.ReactNode;
}
export default function TableCard({
section,
name,
description,
link,
actions,
}: ITableCardProps) {
return (
<Card style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<CardActionArea component={Link} to={link}>
<CardContent style={{ paddingBottom: 0 }}>
<Typography variant="overline" component="p">
{section}
</Typography>
<Typography variant="h6" component="h3" gutterBottom>
{name}
</Typography>
</CardContent>
</CardActionArea>
<CardContent style={{ flexGrow: 1, paddingTop: 0 }}>
<Typography
color="textSecondary"
sx={{
minHeight: (theme) =>
(theme.typography.body2.lineHeight as number) * 2 + "em",
display: "flex",
flexDirection: "column",
gap: 1,
}}
component="div"
>
{description && (
<RenderedMarkdown
children={description}
//restrictionPreset="singleLine"
/>
)}
</Typography>
</CardContent>
<CardActions>
<Button
variant="text"
color="primary"
endIcon={<GoIcon />}
component={Link}
to={link}
>
Open
</Button>
<div style={{ flexGrow: 1 }} />
{actions}
</CardActions>
</Card>
);
}

View File

@@ -0,0 +1,42 @@
import {
Card,
CardContent,
Typography,
CardActions,
Skeleton,
} from "@mui/material";
export default function TableCardSkeleton() {
return (
<Card style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<CardContent style={{ flexGrow: 1 }}>
<Typography variant="overline">
<Skeleton width={80} />
</Typography>
<Typography variant="h6" gutterBottom>
<Skeleton width={180} />
</Typography>
<Typography
color="textSecondary"
sx={{
minHeight: (theme) =>
(theme.typography.body2.lineHeight as number) * 2 + "em",
}}
>
<Skeleton width={120} />
</Typography>
</CardContent>
<CardActions sx={{ mb: 1, mx: 1 }}>
<Skeleton
width={60}
height={20}
variant="rectangular"
sx={{ borderRadius: 1, mr: "auto" }}
/>
<Skeleton variant="circular" width={24} height={24} />
</CardActions>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
import { TransitionGroup } from "react-transition-group";
import { Box, Grid, Collapse } from "@mui/material";
import SectionHeading from "@src/components/SectionHeading";
import TableCard from "./TableCard";
import SlideTransition from "@src/components/Modal/SlideTransition";
import { TableSettings } from "@src/atoms/globalScope";
export interface ITableGridProps {
sections: Record<string, TableSettings[]>;
getLink: (table: TableSettings) => string;
getActions?: (table: TableSettings) => React.ReactNode;
}
export default function TableGrid({
sections,
getLink,
getActions,
}: ITableGridProps) {
return (
<TransitionGroup>
{Object.entries(sections).map(
([sectionName, sectionTables], sectionIndex) => {
const tableItems = sectionTables
.map((table, tableIndex) => {
if (!table) return null;
return (
<SlideTransition
key={table.id}
appear
timeout={(sectionIndex + 1) * 100 + tableIndex * 50}
>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TableCard
{...table}
link={getLink(table)}
actions={getActions ? getActions(table) : null}
/>
</Grid>
</SlideTransition>
);
})
.filter((item) => item !== null);
if (tableItems.length === 0) return null;
return (
<Collapse key={sectionName}>
<Box component="section" sx={{ mt: 4 }}>
<SlideTransition
key={"grid-section-" + sectionName}
in
timeout={(sectionIndex + 1) * 100}
>
<SectionHeading sx={{ pl: 2, pr: 1.5 }}>
{sectionName}
</SectionHeading>
</SlideTransition>
<Grid component={TransitionGroup} container spacing={2}>
{tableItems}
</Grid>
</Box>
</Collapse>
);
}
)}
</TransitionGroup>
);
}

View File

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

View File

@@ -0,0 +1,71 @@
import { TransitionGroup } from "react-transition-group";
import { Box, Paper, Collapse, List } from "@mui/material";
import SectionHeading from "@src/components/SectionHeading";
import TableListItem from "./TableListItem";
import SlideTransition from "@src/components/Modal/SlideTransition";
import { TableSettings } from "@src/atoms/globalScope";
export interface ITableListProps {
sections: Record<string, TableSettings[]>;
getLink: (table: TableSettings) => string;
getActions?: (table: TableSettings) => React.ReactNode;
}
export default function TableList({
sections,
getLink,
getActions,
}: ITableListProps) {
return (
<TransitionGroup>
{Object.entries(sections).map(
([sectionName, sectionTables], sectionIndex) => {
const tableItems = sectionTables
.map((table) => {
if (!table) return null;
return (
<Collapse key={table.id}>
<TableListItem
{...table}
link={getLink(table)}
actions={getActions ? getActions(table) : null}
/>
</Collapse>
);
})
.filter((item) => item !== null);
if (tableItems.length === 0) return null;
return (
<Collapse key={sectionName}>
<Box component="section" sx={{ mt: 4 }}>
<SlideTransition
key={"list-section-" + sectionName}
in
timeout={(sectionIndex + 1) * 100}
>
<SectionHeading sx={{ pl: 2, pr: 1 }}>
{sectionName}
</SectionHeading>
</SlideTransition>
<SlideTransition in timeout={(sectionIndex + 1) * 100}>
<Paper>
<List disablePadding>
<TransitionGroup>{tableItems}</TransitionGroup>
</List>
</Paper>
</SlideTransition>
</Box>
</Collapse>
);
}
)}
</TransitionGroup>
);
}

View File

@@ -0,0 +1,81 @@
import { Link } from "react-router-dom";
import {
ListItem,
ListItemButton,
Typography,
IconButton,
} from "@mui/material";
import GoIcon from "@mui/icons-material/ArrowForward";
import RenderedMarkdown from "@src/components/RenderedMarkdown";
import { TableSettings } from "@src/atoms/globalScope";
export interface ITableListItemProps extends TableSettings {
link: string;
actions?: React.ReactNode;
}
export default function TableListItem({
// section,
name,
description,
link,
actions,
}: ITableListItemProps) {
return (
<ListItem disableGutters disablePadding>
<ListItemButton
component={Link}
to={link}
sx={{
alignItems: "baseline",
height: 48,
py: 0,
pr: 0,
borderRadius: 2,
"& > *": { lineHeight: "48px !important" },
flexWrap: "nowrap",
overflow: "hidden",
flexBasis: 160 + 16,
flexGrow: 0,
flexShrink: 0,
mr: 2,
}}
>
<Typography component="h3" variant="button" noWrap>
{name}
</Typography>
</ListItemButton>
<Typography
color="textSecondary"
component="div"
noWrap
sx={{ flexGrow: 1, "& *": { display: "inline" } }}
>
{description && (
<RenderedMarkdown
children={description}
restrictionPreset="singleLine"
/>
)}
</Typography>
<div style={{ flexShrink: 0 }}>
{actions}
<IconButton
size="large"
color="primary"
component={Link}
to={link}
sx={{ display: { xs: "none", sm: "inline-flex" } }}
>
<GoIcon />
</IconButton>
</div>
</ListItem>
);
}

View File

@@ -0,0 +1,23 @@
import { ListItem, Skeleton } from "@mui/material";
export default function TableListItemSkeleton() {
return (
<ListItem disableGutters disablePadding style={{ height: 48 }}>
<Skeleton width={160} sx={{ mx: 2, flexShrink: 0 }} />
<Skeleton sx={{ mr: 2, flexBasis: 240, flexShrink: 1 }} />
<Skeleton
variant="circular"
width={24}
height={24}
sx={{ ml: "auto", mr: 3, flexShrink: 0 }}
/>
<Skeleton
variant="circular"
width={24}
height={24}
sx={{ mr: 1.5, flexShrink: 0 }}
/>
</ListItem>
);
}

View File

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

View File

@@ -14,6 +14,9 @@ export enum ROUTES {
setup = "/setup",
pageNotFound = "/404",
tables = "/tables",
automations = "/automations",
table = "/table",
tableWithId = "/table/:id",
tableGroup = "/tableGroup",
@@ -27,8 +30,8 @@ export enum ROUTES {
}
export const ROUTE_TITLES = {
[ROUTES.home]: {
title: "Home",
[ROUTES.tables]: {
title: "Tables",
titleComponent: (open, pinned) =>
!(open && pinned) && (
<Logo

View File

@@ -1,4 +1,4 @@
import { useAtom } from "jotai";
import { useAtom, useSetAtom } from "jotai";
import { find, groupBy } from "lodash-es";
import {
@@ -11,13 +11,14 @@ import {
ListItemText,
Divider,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/MenuOpen";
import PinIcon from "@mui/icons-material/PushPinOutlined";
import UnpinIcon from "@mui/icons-material/PushPin";
import HomeIcon from "@mui/icons-material/HomeOutlined";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import ProjectSettingsIcon from "@mui/icons-material/BuildCircleOutlined";
import UserManagementIcon from "@mui/icons-material/AccountCircleOutlined";
import CloseIcon from "@mui/icons-material/MenuOpen";
import PinIcon from "@mui/icons-material/PushPinOutlined";
import UnpinIcon from "@mui/icons-material/PushPin";
import AddIcon from "@mui/icons-material/Add";
import { APP_BAR_HEIGHT } from ".";
import Logo from "@src/assets/Logo";
@@ -31,6 +32,7 @@ import {
userSettingsAtom,
tablesAtom,
TableSettings,
tableSettingsDialogAtom,
} from "@src/atoms/globalScope";
import { ROUTES } from "@src/constants/routes";
@@ -54,6 +56,10 @@ export default function NavDrawer({
const [tables] = useAtom(tablesAtom, globalScope);
const [userSettings] = useAtom(userSettingsAtom, globalScope);
const [userRoles] = useAtom(userRolesAtom, globalScope);
const openTableSettingsDialog = useSetAtom(
tableSettingsDialogAtom,
globalScope
);
const favorites = Array.isArray(userSettings.favoriteTables)
? userSettings.favoriteTables
@@ -65,7 +71,9 @@ export default function NavDrawer({
...groupBy(tables, "section"),
};
const closeDrawer = (e: {}) => props.onClose(e, "escapeKeyDown");
const closeDrawer = pinned
? undefined
: (e: {}) => props.onClose(e, "escapeKeyDown");
return (
<Drawer
@@ -134,21 +142,20 @@ export default function NavDrawer({
<nav>
<List disablePadding>
<li>
<NavItem
to={ROUTES.home}
onClick={pinned ? undefined : closeDrawer}
>
<NavItem to={ROUTES.home} onClick={closeDrawer}>
<ListItemIcon>
<HomeIcon />
</ListItemIcon>
<ListItemText primary="Home" />
</NavItem>
</li>
{userRoles.includes("ADMIN") && (
<Divider variant="middle" sx={{ my: 1 }} />
)}
<li>
<NavItem
to={ROUTES.userSettings}
onClick={pinned ? undefined : closeDrawer}
>
<NavItem to={ROUTES.userSettings} onClick={closeDrawer}>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
@@ -157,10 +164,7 @@ export default function NavDrawer({
</li>
{userRoles.includes("ADMIN") && (
<li>
<NavItem
to={ROUTES.projectSettings}
onClick={pinned ? undefined : closeDrawer}
>
<NavItem to={ROUTES.projectSettings} onClick={closeDrawer}>
<ListItemIcon>
<ProjectSettingsIcon />
</ListItemIcon>
@@ -171,10 +175,7 @@ export default function NavDrawer({
)}
{userRoles.includes("ADMIN") && (
<li>
<NavItem
to={ROUTES.userManagement}
onClick={pinned ? undefined : closeDrawer}
>
<NavItem to={ROUTES.userManagement} onClick={closeDrawer}>
<ListItemIcon>
<UserManagementIcon />
</ListItemIcon>
@@ -185,6 +186,24 @@ export default function NavDrawer({
<Divider variant="middle" sx={{ my: 1 }} />
<li>
<NavItem
{...({ component: "button" } as any)}
style={{ textAlign: "left" }}
onClick={(e) => {
if (closeDrawer) closeDrawer(e);
openTableSettingsDialog({});
}}
>
<ListItemIcon>
<AddIcon />
</ListItemIcon>
<ListItemText primary="Create table…" />
</NavItem>
</li>
<Divider variant="middle" sx={{ my: 1 }} />
{sections &&
Object.entries(sections)
.filter(([, tables]) => tables.length > 0)
@@ -193,7 +212,7 @@ export default function NavDrawer({
key={section}
section={section}
tables={tables}
closeDrawer={pinned ? undefined : closeDrawer}
closeDrawer={closeDrawer}
/>
))}
</List>

View File

@@ -184,7 +184,8 @@ export default function Navigation({ children }: React.PropsWithChildren<{}>) {
: "100%",
}}
>
{children || <Outlet />}
<Outlet />
{children}
</div>
</Suspense>
</ErrorBoundary>

228
src/pages/Tables.tsx Normal file
View File

@@ -0,0 +1,228 @@
import { useAtom, useSetAtom } from "jotai";
import { find, groupBy } from "lodash-es";
import {
Container,
Stack,
Typography,
ToggleButtonGroup,
ToggleButton,
Tooltip,
Fab,
Checkbox,
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 FloatingSearch from "@src/components/FloatingSearch";
import SlideTransition from "@src/components/Modal/SlideTransition";
import TableGrid from "@src/components/Tables/TableGrid";
import TableList from "@src/components/Tables/TableList";
import HomeWelcomePrompt from "@src/components/Tables/HomeWelcomePrompt";
import EmptyState from "@src/components/EmptyState";
import {
globalScope,
userRolesAtom,
userSettingsAtom,
updateUserSettingsAtom,
tablesAtom,
tablesViewAtom,
tableSettingsDialogAtom,
TableSettings,
} from "@src/atoms/globalScope";
import { ROUTES } from "@src/constants/routes";
import useBasicSearch from "@src/hooks/useBasicSearch";
import { APP_BAR_HEIGHT } from "@src/layouts/Navigation";
const SEARCH_KEYS = ["id", "name", "section", "description"];
export default function HomePage() {
const [userRoles] = useAtom(userRolesAtom, globalScope);
const [userSettings] = useAtom(userSettingsAtom, globalScope);
const [updateUserSettings] = useAtom(updateUserSettingsAtom, globalScope);
const [tables] = useAtom(tablesAtom, globalScope);
const [view, setView] = useAtom(tablesViewAtom, globalScope);
const openTableSettingsDialog = useSetAtom(
tableSettingsDialogAtom,
globalScope
);
const [results, query, handleQuery] = useBasicSearch(
tables ?? [],
SEARCH_KEYS
);
const favorites = Array.isArray(userSettings.favoriteTables)
? userSettings.favoriteTables
: [];
const sections: Record<string, TableSettings[]> = {
Favorites: favorites.map((id) => find(results, { id })) as TableSettings[],
...groupBy(results, "section"),
};
if (!Array.isArray(tables))
throw new Error(
"Project settings are not configured correctly. `tables` is not an array."
);
const createTableFab = (
<Tooltip title="Create table">
<Zoom in>
<Fab
color="secondary"
aria-label="Create table"
onClick={() => openTableSettingsDialog({ mode: "create" })}
sx={{
zIndex: "speedDial",
position: "fixed",
bottom: (theme) => ({
xs: `max(${theme.spacing(2)}, env(safe-area-inset-bottom))`,
sm: `max(${theme.spacing(3)}, env(safe-area-inset-bottom))`,
}),
right: (theme) => ({
xs: `max(${theme.spacing(2)}, env(safe-area-inset-right))`,
sm: `max(${theme.spacing(3)}, env(safe-area-inset-right))`,
}),
}}
>
<AddIcon />
</Fab>
</Zoom>
</Tooltip>
);
if (tables.length === 0) {
if (userRoles.includes("ADMIN"))
return (
<>
<HomeWelcomePrompt />
{createTableFab}
</>
);
return (
<EmptyState
message="No tables"
description="There are no tables in this project. Sign in with an ADMIN account to create tables."
fullScreen
style={{ marginTop: -APP_BAR_HEIGHT }}
/>
);
}
const getLink = (table: TableSettings) =>
`${
table.tableType === "primaryCollection" ? ROUTES.table : ROUTES.tableGroup
}/${table.id.replace(/\//g, "~2F")}`;
const handleFavorite =
(id: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
const favoriteTables = e.target.checked
? [...favorites, id]
: favorites.filter((f) => f !== id);
if (updateUserSettings) updateUserSettings({ favoriteTables });
};
const getActions = (table: TableSettings) => (
<>
{userRoles.includes("ADMIN") && (
<IconButton
aria-label="Edit table"
onClick={() =>
openTableSettingsDialog({ mode: "update", data: table })
}
size={view === "list" ? "large" : undefined}
>
<EditIcon />
</IconButton>
)}
<Checkbox
onChange={handleFavorite(table.id)}
checked={favorites.includes(table.id)}
icon={<FavoriteBorderIcon />}
checkedIcon={
<Zoom in>
<FavoriteIcon />
</Zoom>
}
name={`favorite-${table.id}`}
inputProps={{ "aria-label": "Favorite" }}
sx={view === "list" ? { p: 1.5 } : undefined}
color="secondary"
/>
</>
);
return (
<Container component="main" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
<FloatingSearch
label="Search tables"
onChange={(e) => handleQuery(e.target.value)}
paperSx={{
maxWidth: (theme) => ({ md: theme.breakpoints.values.sm - 48 }),
mb: { xs: 2, md: -6 },
}}
/>
<SlideTransition in timeout={50}>
<Stack
direction="row"
spacing={2}
alignItems="center"
justifyContent="space-between"
>
<Typography
variant="h6"
component="h1"
sx={{ pl: 2, cursor: "default" }}
>
{query ? `${results.length} of ${tables.length}` : tables.length}{" "}
tables
</Typography>
<ToggleButtonGroup
value={view}
size="large"
exclusive
onChange={(_, v) => {
if (v !== null) setView(v);
}}
aria-label="Table view"
sx={{ "& .MuiToggleButton-root": { borderRadius: 2 } }}
>
<ToggleButton value="list" aria-label="List view">
<ViewListIcon style={{ transform: "rotate(180deg)" }} />
</ToggleButton>
<ToggleButton value="grid" aria-label="Grid view">
<ViewGridIcon />
</ToggleButton>
</ToggleButtonGroup>
</Stack>
</SlideTransition>
{view === "list" ? (
<TableList
sections={sections}
getLink={getLink}
getActions={getActions}
/>
) : (
<TableGrid
sections={sections}
getLink={getLink}
getActions={getActions}
/>
)}
{userRoles.includes("ADMIN") && createTableFab}
</Container>
);
}

View File

@@ -0,0 +1,4 @@
declare module "json-stable-stringify-without-jsonify" {
const stringify: any;
export default stringify;
}

1424
yarn.lock

File diff suppressed because it is too large Load Diff