Merge pull request #77 from AntlerVC/feature/callable-action

Feature/callable action
This commit is contained in:
AntlerEngineering
2020-02-14 10:56:49 +11:00
committed by GitHub
24 changed files with 498 additions and 126 deletions

7
.gitignore vendored
View File

@@ -15,6 +15,13 @@ cloud_functions/functions/node_modules
www/build
cloud_functions/functions/lib
# cloud function config
cloud_functions/functions/src/collectionSync/config.json
cloud_functions/functions/src/history/config.json
cloud_functions/functions/src/algolia/config.json
cloud_functions/functions/firebase-credentials.json
# misc
.DS_Store
.env.local

View File

@@ -1,6 +1,7 @@
{
"functions": {
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run fetchConfig",
"npm --prefix \"$RESOURCE_DIR\" run lint",
"npm --prefix \"$RESOURCE_DIR\" run build"
]

View File

@@ -0,0 +1,42 @@
import * as fs from "fs";
// Initialize Firebase Admin
import * as admin from "firebase-admin";
// Initialize Firebase Admin
const serviceAccount = require(`./firebase-credentials.json`);
console.log(`Running on ${serviceAccount.project_id}`);
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: `https://${serviceAccount.project_id}.firebaseio.com`,
});
const db = admin.firestore();
const docConfig2json = async (docPath: string, jsonPath: string) => {
const doc = await db.doc(docPath).get();
const data = doc.data();
const jsonData = JSON.stringify(data ? data.config : "");
fs.writeFileSync(jsonPath, jsonData);
};
// Initialize Cloud Firestore Database
const main = async () => {
await docConfig2json(
"_FIRETABLE_/_SETTINGS_/_CONFIG_/_HISTORY_",
"./src/history/config.json"
);
await docConfig2json(
"_FIRETABLE_/_SETTINGS_/_CONFIG_/_ALGOLIA_",
"./src/algolia/config.json"
);
await docConfig2json(
"_FIRETABLE_/_SETTINGS_/_CONFIG_/_COLLECTION_SYNC_",
"./src/collectionSync/config.json"
);
return true;
};
main()
.catch(err => console.log(err))
.then(() => console.log("this will succeed"))
.catch(() => "obligatory catch");

View File

@@ -0,0 +1,12 @@
{
"type": "service_account",
"project_id": "",
"private_key_id": "",
"private_key":""
"client_email": "",
"client_id": "",
"auth_uri": "",
"token_uri": "",
"auth_provider_x509_cert_url": "",
"client_x509_cert_url": ""
}

View File

@@ -1,7 +1,7 @@
{
"name": "functions",
"scripts": {
"generator": "cd generator;node index.js",
"fetchConfig": "ts-node fetchConfig.ts",
"lint": "tslint --project tsconfig.json",
"build": "tsc",
"serve": "npm run build && firebase serve --only functions",
@@ -17,15 +17,18 @@
"dependencies": {
"@types/algoliasearch": "^3.34.5",
"@types/json2csv": "^4.5.0",
"@types/lodash": "^4.14.149",
"algoliasearch": "^3.35.1",
"firebase-admin": "~7.0.0",
"firebase-functions": "^3.3.0",
"json2csv": "^4.5.4"
"json2csv": "^4.5.4",
"lodash": "^4.17.15"
},
"devDependencies": {
"husky": "^3.0.9",
"tslint": "^5.12.0",
"typescript": "^3.2.2"
"typescript": "^3.2.2",
"ts-node": "^8.6.2"
},
"private": true
}

View File

@@ -1,9 +0,0 @@
const algoliaConfig = [
{
// example collection config
name: "users",
fieldsToSync: ["firstName", "lastName"],
},
];
export default algoliaConfig;

View File

@@ -1,6 +1,6 @@
import * as algoliasearch from "algoliasearch";
import * as functions from "firebase-functions";
import * as _ from "lodash";
import { env } from "../config";
const APP_ID = env.algolia.app;
@@ -8,13 +8,49 @@ const ADMIN_KEY = env.algolia.key;
const client = algoliasearch(APP_ID, ADMIN_KEY);
const filterSnapshot = (
field: { docPath: string; snapshot: any },
preservedKeys: string[]
) => {
return {
docPath: field.docPath,
...preservedKeys.reduce((acc: any, currentKey: string) => {
const value = _.get(field.snapshot, currentKey);
if (value) {
return { ...acc, snapshot: { [currentKey]: value, ...acc.snapshot } };
} else return acc;
}, {}),
};
};
// returns object of fieldsToSync
const algoliaReducer = (docData: FirebaseFirestore.DocumentData) => (
acc: any,
curr: string
curr: string | { fieldName: string; snapshotFields: string[] }
) => {
if (docData[curr]) return { ...acc, [curr]: docData[curr] };
else return acc;
if (typeof curr === "string") {
if (docData[curr] && typeof docData[curr].toDate === "function") {
return {
...acc,
[curr]: docData[curr].toDate().getTime() / 1000,
};
} else if (docData[curr]) {
return { ...acc, [curr]: docData[curr] };
} else {
return acc;
}
} else {
if (docData[curr.fieldName] && curr.snapshotFields) {
return {
...acc,
[curr.fieldName]: docData[curr.fieldName].map(snapshot =>
filterSnapshot(snapshot, curr.snapshotFields)
),
};
} else {
return acc;
}
}
};
const addToAlgolia = (fieldsToSync: string[]) => (

View File

@@ -0,0 +1,58 @@
import * as functions from "firebase-functions";
import { db } from "./config";
// example callable function
export const createInFounders = functions.https.onCall(
async (
data: {
ref: {
id: string;
path: string;
parentId: string;
};
row: any;
},
context: functions.https.CallableContext
) => {
const { row, ref } = data;
console.log(context.auth);
if (context.auth && context.auth.token.email.includes("@antler.co")) {
const fieldsToSync = [
"firstName",
"lastName",
"preferredName",
"personalBio",
"founderType",
"cohort",
"email",
"profilePhoto",
"twitter",
"employerLogos",
"linkedin",
"publicProfile",
"companies",
];
const syncData = fieldsToSync.reduce((acc: any, curr: string) => {
if (row[curr]) {
acc[curr] = row[curr];
return acc;
} else return acc;
}, {});
await db
.collection("founders")
.doc(ref.id)
.set(syncData, { merge: true });
return {
message: "Founder created!",
cellValue: { redo: false, status: "live", undo: true },
success: true,
};
} else {
return {
message: "unauthorized function",
// cellValue: { redo: false, status: "complete" },
success: false,
};
}
}
);

View File

@@ -1,20 +0,0 @@
import * as functions from "firebase-functions";
import { auth } from "./config";
export const users = functions.firestore
.document("users/{id}")
.onUpdate(async (change, context) => {
const afterData = change.after.data();
const beforeData = change.before.data();
if (afterData && beforeData && afterData.startup && afterData.startup[0]) {
if (afterData.startup !== beforeData.startup) {
const customClaims = {
portfolio: afterData.startup.map(
(data: any) => data.snapshot.objectID
),
};
await auth.setCustomUserClaims(context.params.id, customClaims);
}
}
return true;
});

View File

@@ -0,0 +1,56 @@
import * as functions from "firebase-functions";
import { db } from "../config";
// returns object of fieldsToSync
const docReducer = (docData: FirebaseFirestore.DocumentData) => (
acc: any,
curr: string
) => {
if (docData[curr]) return { ...acc, [curr]: docData[curr] };
else return acc;
};
/**
*
* @param targetCollection
* @param fieldsToSync
*/
const syncDoc = (targetCollection: string, fieldsToSync: string[]) => (
snapshot: FirebaseFirestore.DocumentSnapshot
) => {
const docId = snapshot.id;
const docData = snapshot.data();
if (!docData) return false; // returns if theres no data in the doc
const syncData = fieldsToSync.reduce(docReducer(docData), {});
if (Object.keys(syncData).length === 0) return false; // returns if theres nothing to sync
db.collection(targetCollection)
.doc(docId)
.set(syncData, { merge: true })
.catch(error => console.error(error));
return true;
};
/**
* onUpdate change to snapshot adapter
* @param targetCollection
* @param fieldsToSync
*/
const syncDocOnUpdate = (targetCollection: string, fieldsToSync: string[]) => (
snapshot: functions.Change<FirebaseFirestore.DocumentSnapshot>
) => syncDoc(targetCollection, fieldsToSync)(snapshot.after);
/**
* returns 3 different trigger functions (onCreate,onUpdate,onDelete) in an object
* @param collection configuration object
*/
const collectionSyncFnsGenerator = collection => ({
onCreate: functions.firestore
.document(`${collection.source}/{docId}`)
.onCreate(syncDoc(collection.target, collection.fieldsToSync)),
onUpdate: functions.firestore
.document(`${collection.source}/{docId}`)
.onUpdate(syncDocOnUpdate(collection.target, collection.fieldsToSync)),
});
export default collectionSyncFnsGenerator;

View File

@@ -0,0 +1,33 @@
import * as functions from "firebase-functions";
import * as _ from "lodash";
import { db } from "../config";
const historySnapshot = (collection: string, trackedFields) => async (
change: functions.Change<FirebaseFirestore.DocumentSnapshot>,
context: functions.EventContext
) => {
const docId = context.params.docId;
const before = change.before.data();
const after = change.after.data();
if (!before || !after) return false;
const trackedChanges: any = {};
trackedFields.forEach(field => {
if (!_.isEqual(before[field], after[field]))
trackedChanges[field] = after[field];
});
if (!_.isEmpty(trackedChanges)) {
await db
.collection(collection)
.doc(docId)
.collection("historySnapshots")
.add({ ...before, archivedAt: new Date() });
return true;
} else return false;
};
const historySnapshotFnsGenerator = collection =>
functions.firestore
.document(`${collection.name}/{docId}`)
.onUpdate(historySnapshot(collection.name, collection.trackedFields));
export default historySnapshotFnsGenerator;

View File

@@ -1,6 +1,32 @@
export { exportTable } from "./export";
import algoliaFnsGenerator from "./algolia";
import algoliaConfig from "./algolia/algoliaConfig";
export const algolia = algoliaConfig.reduce((acc: any, collection) => {
import * as algoliaConfig from "./algolia/config.json";
import collectionSyncFnsGenerator from "./collectionSync";
import * as collectionSyncConfig from "./collectionSync/config.json";
import collectionSnapshotFnsGenerator from "./history";
import * as collectionHistoryConfig from "./history/config.json";
export { exportTable } from "./export";
import * as callableFns from "./callable";
const callable = callableFns;
const algolia = algoliaConfig.reduce((acc: any, collection) => {
return { ...acc, [collection.name]: algoliaFnsGenerator(collection) };
}, {});
const sync = collectionSyncConfig.reduce((acc: any, collection) => {
return {
...acc,
[`${collection.source}2${collection.target}`]: collectionSyncFnsGenerator(
collection
),
};
}, {});
const history = collectionHistoryConfig.reduce((acc: any, collection) => {
return {
...acc,
[collection.name]: collectionSnapshotFnsGenerator(collection),
};
}, {});
export const FIRETABLE = { callable, algolia, sync, history };

View File

@@ -1,20 +0,0 @@
/* tslint-disable */
import * as functions from "firebase-functions";
import { db} from "./config";
export const users = functions.firestore
.document("users/{id}")
.onUpdate(async(change, context) => {
const afterData = change.after.data();
if (afterData) {
if( afterData.founder[0].docPath){
const updates = {firstName:afterData.firstName,lastName:afterData.lastName,preferredName:afterData.preferredName,background:afterData.personalBio,role:afterData.role,profilePhoto:afterData.profilePhoto}
console.log(`updates FROM users/${context.params.id} TO ${afterData.founder[0].docPath}`, updates)
await db.doc(afterData.founder[0].docPath).set(updates, { merge: true });
}
}
return true;
});

View File

@@ -7,8 +7,9 @@
"sourceMap": true,
"strict": true,
"noImplicitAny": false,
"resolveJsonModule": true,
"target": "es2017"
},
"compileOnSave": true,
"include": ["src"]
"include": ["src", "fetchConfig.ts"]
}

View File

@@ -255,6 +255,11 @@
dependencies:
"@types/node" "*"
"@types/lodash@^4.14.149":
version "4.14.149"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440"
integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==
"@types/long@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.0.tgz#719551d2352d301ac8b81db732acb6bdc28dbdef"
@@ -403,6 +408,11 @@ are-we-there-yet@~1.1.2:
delegates "^1.0.0"
readable-stream "^2.0.6"
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
argparse@^1.0.7:
version "1.0.10"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
@@ -1620,7 +1630,7 @@ lodash.once@^4.0.0:
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
lodash@^4.17.14:
lodash@^4.17.14, lodash@^4.17.15:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
@@ -1649,6 +1659,11 @@ make-dir@^1.0.0:
dependencies:
pify "^3.0.0"
make-error@^1.1.1:
version "1.3.5"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8"
integrity sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g==
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -2328,6 +2343,19 @@ snakeize@^0.1.0:
resolved "https://registry.yarnpkg.com/snakeize/-/snakeize-0.1.0.tgz#10c088d8b58eb076b3229bb5a04e232ce126422d"
integrity sha1-EMCI2LWOsHazIpu1oE4jLOEmQi0=
source-map-support@^0.5.6:
version "0.5.16"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.16.tgz#0ae069e7fe3ba7538c64c98515e35339eac5a042"
integrity sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==
dependencies:
buffer-from "^1.0.0"
source-map "^0.6.0"
source-map@^0.6.0:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
spdx-correct@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4"
@@ -2502,6 +2530,17 @@ toidentifier@1.0.0:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
ts-node@^8.6.2:
version "8.6.2"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.6.2.tgz#7419a01391a818fbafa6f826a33c1a13e9464e35"
integrity sha512-4mZEbofxGqLL2RImpe3zMJukvEvcO1XP8bj8ozBPySdCUXEcU5cIRwR0aM3R+VoZq7iXc8N86NC0FspGRqP4gg==
dependencies:
arg "^4.1.0"
diff "^4.0.1"
make-error "^1.1.1"
source-map-support "^0.5.6"
yn "3.1.1"
tslib@1.9.3:
version "1.9.3"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286"
@@ -2705,3 +2744,8 @@ yargs@^3.10.0:
string-width "^1.0.1"
window-size "^0.1.4"
y18n "^3.2.0"
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==

View File

@@ -1,8 +1,12 @@
import React from "react";
import React, { useContext } from "react";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Button from "@material-ui/core/Button";
import { db } from "../../firebase";
import useDoc from "hooks/useDoc";
import { SnackContext } from "../../contexts/snackContext";
import IconButton from "@material-ui/core/IconButton";
import Grid from "@material-ui/core/Grid";
import Typography from "@material-ui/core/Typography";
import PlayIcon from "@material-ui/icons/PlayCircleOutline";
import ReplayIcon from "@material-ui/icons/Replay";
import { cloudFunction } from "../../firebase/callables";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
@@ -17,6 +21,7 @@ const useStyles = makeStyles((theme: Theme) =>
interface Props {
value: any;
fieldName: string;
callableName: string;
// row: {
// ref: firebase.firestore.DocumentReference;
// id: string;
@@ -25,49 +30,72 @@ interface Props {
// updatedAt: any;
// };
row: any;
scripts: any;
onSubmit: Function;
}
export default function Action(props: Props) {
const { row, value, fieldName, onSubmit } = props;
const { row, value, fieldName, onSubmit, scripts, callableName } = props;
const { createdAt, updatedAt, rowHeight, id, ref, ...docData } = row;
const classes = useStyles();
const handleClick = () => {
const fieldsToSync = [
"firstName",
"lastName",
"preferredName",
"personalBio",
"founderType",
"cohort",
"ordering",
"email",
"profilePhoto",
"twitter",
"employerLogos",
"linkedin",
"publicProfile",
"companies",
];
const data = fieldsToSync.reduce((acc: any, curr: string) => {
if (row[curr]) {
acc[curr] = row[curr];
return acc;
} else return acc;
const snack = useContext(SnackContext);
const handleRun = () => {
// eval(scripts.onClick)(row);
const cleanRow = Object.keys(row).reduce((acc: any, key: string) => {
if (row[key]) return { ...acc, [key]: row[key] };
else return acc;
}, {});
db.collection(fieldName)
.doc(id)
.set(data, { merge: true });
onSubmit(true);
cleanRow.ref = "cleanRow.ref";
delete cleanRow.rowHeight;
delete cleanRow.updatedFields;
cloudFunction(
callableName,
{
ref: {
path: ref.path,
id: ref.id,
},
row: docData,
},
response => {
const { message, cellValue } = response.data;
snack.open({ message, severity: "success" });
if (cellValue) {
onSubmit(cellValue);
}
},
o => snack.open({ message: JSON.stringify(o), severity: "error" })
);
};
return (
<Button
variant="outlined"
onClick={handleClick}
color="primary"
className={classes.button}
// disabled={!!value}
>
{value ? `done` : `Create in ${fieldName}`}
</Button>
);
const classes = useStyles();
if (value && value.status)
return (
<Grid
container
direction="row"
justify="space-between"
alignItems="center"
>
<Typography variant="body1"> {value.status}</Typography>
<IconButton onClick={handleRun} disabled={!value.redo}>
<ReplayIcon />
</IconButton>
</Grid>
);
else
return (
<Grid
container
direction="row"
justify="space-between"
alignContent="center"
>
<Typography variant="body1">
{" "}
{callableName.replace("callable-", "")}
</Typography>
<IconButton onClick={handleRun}>
<PlayIcon />
</IconButton>
</Grid>
);
}

View File

@@ -1,8 +1,11 @@
import React from "react";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Button from "@material-ui/core/Button";
import useRouter from "../../hooks/useRouter";
import OpenIcon from "@material-ui/icons/OpenInNew";
import useRouter from "../../hooks/useRouter";
import queryString from "query-string";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
button: {
@@ -34,11 +37,19 @@ export default function SubTable(props: Props) {
const router = useRouter();
const classes = useStyles();
const parentLabels = queryString.parse(router.location.search).parentLabel;
const handleClick = () => {
const subTablePath =
encodeURIComponent(`${row.ref.path}/${fieldName}`) +
`?parentLabel=${row[parentLabel]}`;
router.history.push(subTablePath);
if (parentLabels) {
const subTablePath =
encodeURIComponent(`${row.ref.path}/${fieldName}`) +
`?parentLabel=${parentLabels},${row[parentLabel]}`;
router.history.push(subTablePath);
} else {
const subTablePath =
encodeURIComponent(`${row.ref.path}/${fieldName}`) +
`?parentLabel=${row[parentLabel]}`;
router.history.push(subTablePath);
}
};
return (

View File

@@ -1,26 +1,40 @@
import React, { useContext, useEffect } from "react";
import Snackbar, { SnackbarOrigin } from "@material-ui/core/Snackbar";
import { SnackContext } from "../contexts/snackContext";
import MuiAlert, { AlertProps } from "@material-ui/lab/Alert";
import { makeStyles, Theme } from "@material-ui/core/styles";
function Alert(props: AlertProps) {
return <MuiAlert elevation={6} variant="filled" {...props} />;
}
export default function Snack() {
const snackContext = useContext(SnackContext);
const { position, isOpen, close, message, duration, action } = snackContext;
const {
position,
isOpen,
close,
message,
duration,
action,
severity,
} = snackContext;
const { vertical, horizontal } = position;
useEffect(() => {
if (isOpen) setTimeout(close, 10000);
}, [isOpen]);
console.log("severity", severity);
return (
<Snackbar
anchorOrigin={{ vertical, horizontal }}
key={`${vertical},${horizontal}`}
open={isOpen}
onClose={close}
ContentProps={{
"aria-describedby": "message-id",
}}
message={<span id="message-id">{message}</span>}
action={action}
/>
>
<Alert onClose={close} action={action} severity={severity}>
{message}
</Alert>
</Snackbar>
);
}

View File

@@ -80,6 +80,7 @@ const ColumnEditor = (props: any) => {
collectionPath: "",
config: {},
parentLabel: "",
callableName: "",
});
const [flags, setFlags] = useState(() => [""]);
const classes = useStyles();
@@ -133,6 +134,7 @@ const ColumnEditor = (props: any) => {
collectionPath: "",
config: {},
parentLabel: "",
callableName: "",
});
};
const onClose = (event: any) => {
@@ -192,6 +194,9 @@ const ColumnEditor = (props: any) => {
if (values.type === FieldType.subTable) {
updatables.push({ field: "parentLabel", value: values.parentLabel });
}
if (values.type === FieldType.action) {
updatables.push({ field: "callableName", value: values.callableName });
}
actions.update(props.column.idx, updatables);
handleClose();
clearValues();
@@ -277,6 +282,14 @@ const ColumnEditor = (props: any) => {
}}
/>
)}
{values.type === FieldType.action && (
<TextField
label={"Callable Name"}
onChange={e => {
setValue("callableName", e.target.value);
}}
/>
)}
<Grid container>
<Grid item xs={6}>
{column.isNew ? (

View File

@@ -67,7 +67,8 @@ const TableHeader = ({
}: Props) => {
const classes = useStyles();
const router = useRouter();
const parentLabel = queryString.parse(router.location.search).parentLabel;
const parentLabel = queryString.parse(router.location.search)
.parentLabel as string;
let breadcrumbs = collection.split("/");
return (
@@ -81,14 +82,32 @@ const TableHeader = ({
) : (
<Breadcrumbs aria-label="breadcrumb">
{breadcrumbs.map((crumb: string, index) => {
if (index === 0)
if (index % 2 === 0)
return (
<Link color="inherit" href={`/table/${crumb}`}>
{crumb}
<Link
color="inherit"
href={`/table/${breadcrumbs
.reduce((acc: string, curr: string, currIndex) => {
if (currIndex < index + 1) return acc + "/" + curr;
else return acc;
}, " ")
.replace(" /", "")}?parentLabel=${parentLabel
.split(",")
.reduce((acc: string, curr, currIndex) => {
if (currIndex > index - 1) return acc + "," + curr;
else return acc;
}, " ")
.replace(" ,", "")}`}
>
{crumb.replace(/([A-Z])/g, " $1")}
</Link>
);
else if (index === 1 && parentLabel)
return <Typography variant="h6">{parentLabel}</Typography>;
else if (index % 2 === 1)
return (
<Typography variant="h6">
{parentLabel.split(",")[Math.ceil(index / 2) - 1]}
</Typography>
);
else
return (
<Typography variant="h6">

View File

@@ -140,6 +140,8 @@ export const cellFormatter = (column: any) => {
return (
<Suspense fallback={<div />}>
<Action
scripts={column.scripts}
callableName={column.callableName}
fieldName={key}
{...props}
onSubmit={onSubmit(key, props.row)}

View File

@@ -3,17 +3,21 @@ import { SnackbarOrigin } from "@material-ui/core/Snackbar";
// Default State of our SnackBar
const position: SnackbarOrigin = { vertical: "bottom", horizontal: "left" };
type Severity = "error" | "success" | "info" | "warning" | undefined;
const severity: Severity = undefined as Severity;
const DEFAULT_STATE = {
isOpen: false, // boolean to control show/hide
message: "", // text to be displayed in SnackBar
duration: 2000, // time SnackBar should be visible
position,
severity,
close: () => {},
open: (props: {
message: string;
duration?: number;
position?: SnackbarOrigin;
action?: JSX.Element;
severity?: "error" | "success" | "info" | "warning" | undefined;
}) => {},
action: <div />,
};

View File

@@ -8,8 +8,8 @@ export enum CLOUD_FUNCTIONS {
export const cloudFunction = (
name: string,
input: any,
success: Function,
fail: Function
success?: Function,
fail?: Function
) => {
const callable = functions.httpsCallable(name);
callable(input)

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
import { auth } from "../firebase";
import { SnackbarOrigin } from "@material-ui/core/Snackbar";
import { SnackContext } from "../contexts/snackContext";
interface ISnackProviderProps {
children: React.ReactNode;
}
@@ -11,6 +12,9 @@ export const SnackProvider: React.FC<ISnackProviderProps> = ({ children }) => {
const [message, setMessage] = useState("");
const [duration, setDuration] = useState(3000);
const [action, setAction] = useState(<div />);
const [severity, setSeverity] = useState<
"error" | "success" | "info" | "warning" | undefined
>("info");
const [position, setPosition] = useState<SnackbarOrigin>({
vertical: "bottom",
horizontal: "left",
@@ -19,15 +23,20 @@ export const SnackProvider: React.FC<ISnackProviderProps> = ({ children }) => {
setIsOpen(false);
setMessage("");
setDuration(0);
setSeverity(undefined);
};
const open = (props: {
message: string;
duration?: number;
position?: SnackbarOrigin;
action?: JSX.Element;
severity?: "error" | "success" | "info" | "warning" | undefined;
}) => {
const { message, duration, position, action } = props;
const { message, duration, position, action, severity } = props;
setMessage(message);
if (severity) {
setSeverity(severity);
}
if (action) {
setAction(action);
}
@@ -41,6 +50,7 @@ export const SnackProvider: React.FC<ISnackProviderProps> = ({ children }) => {
} else {
setPosition({ vertical: "bottom", horizontal: "left" });
}
setIsOpen(true);
};
return (
@@ -53,6 +63,7 @@ export const SnackProvider: React.FC<ISnackProviderProps> = ({ children }) => {
close,
open,
action,
severity: severity,
}}
>
{children}