Merge pull request #123 from AntlerVC/develop

Develop
This commit is contained in:
AntlerEngineering
2020-05-11 17:14:42 +10:00
committed by GitHub
59 changed files with 2108 additions and 1279 deletions

10
.gitignore vendored
View File

@@ -3,6 +3,8 @@
# dependencies
/node_modules
www/node_modules
www/.firebaserc
Firetable/node_modules
cloud_functions/functions/node_modules
/.pnp
@@ -20,17 +22,23 @@ cloud_functions/functions/lib
cloud_functions/functions/src/collectionSync/config.json
cloud_functions/functions/src/history/config.json
cloud_functions/functions/src/algolia/config.json
cloud_functions/functions/src/snapshotSync/config.json
cloud_functions/functions/firebase-credentials.json
cloud_functions/.firebaserc
# misc
.DS_Store
.env*
!.env.example
!.env.enc
!*.enc
npm-debug.log*
yarn-debug.log*
yarn-error.log*
firebase-debug.log*
# Accidental package installs to root directories
yarn.lock

View File

@@ -1,5 +0,0 @@
{
"projects": {
"default":"antler-vc"
}
}

Binary file not shown.

View File

@@ -33,6 +33,10 @@ const main = async () => {
"_FIRETABLE_/_SETTINGS_/_CONFIG_/_COLLECTION_SYNC_",
"./src/collectionSync/config.json"
);
await docConfig2json(
"_FIRETABLE_/_SETTINGS_/_CONFIG_/_SNAPSHOT_SYNC_",
"./src/snapshotSync/config.json"
);
return true;
};

View File

@@ -7,7 +7,7 @@
"serve": "npm run build && firebase serve --only functions",
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --token \"$FIREBASE_TOKEN\" --project antler-vc --only",
"deploy": "firebase deploy",
"logs": "firebase functions:log"
},
"engines": {
@@ -15,6 +15,8 @@
},
"main": "lib/src/index.js",
"dependencies": {
"@google-cloud/cloudbuild": "^1.5.0",
"@google-cloud/firestore": "^3.7.5",
"@types/algoliasearch": "^3.34.5",
"@types/json2csv": "^4.5.0",
"@types/lodash": "^4.14.149",
@@ -27,6 +29,8 @@
"devDependencies": {
"firebase-tools": "^7.16.1",
"husky": "^3.0.9",
"prettier": "^2.0.5",
"pretty-quick": "^2.0.1",
"ts-node": "^8.6.2",
"tslint": "^6.1.0",
"typescript": "^3.2.2"

View File

@@ -0,0 +1,100 @@
import * as functions from "firebase-functions";
import * as firestore from "@google-cloud/firestore";
import { hasAnyRole } from "./utils/auth";
const client = new firestore.v1.FirestoreAdminClient();
// Replace BUCKET_NAME
const bucket = "gs://BUCKET_NAME";
/*
const restoreFirestoreBackup = (collectionIds: string[] = []) => {
const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT;
const databaseName = client.databasePath(projectId, "(default)");
const date = new Date();
const backupFolder = `${date.getFullYear()}-${
date.getMonth() + 1
}-${date.getDate()}`;
const inputUriPrefix = bucket + "/" + backupFolder;
console.log(inputUriPrefix);
return client
.importDocuments({
name: databaseName,
inputUriPrefix,
// Leave collectionIds empty to export all collections
// or set to a list of collection IDs to export,
// collectionIds: ['users', 'posts']
collectionIds,
})
.then((responses) => {
const response = responses[0];
console.log(`Operation Name: ${response["name"]}`);
return response;
})
.catch((err) => {
console.error(err);
throw new Error("import operation failed");
});
};
export const scheduledFirestoreImport = functions.pubsub
.schedule("every 24 hours")
.onRun((context) => {
console.log(context);
return restoreFirestoreBackup();
});
*/
const firestoreBackup = (collectionIds: string[] = []) => {
const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT;
const databaseName = client.databasePath(projectId, "(default)");
const date = new Date();
const backupFolder = `${date.getFullYear()}-${date.getMonth() +
1}-${date.getDate()}`;
return client
.exportDocuments({
name: databaseName,
outputUriPrefix: bucket + "/" + backupFolder,
// Leave collectionIds empty to export all collections
// or set to a list of collection IDs to export,
// collectionIds: ['users', 'posts']
collectionIds,
})
.then(responses => {
const response = responses[0];
console.log(`Operation Name: ${response["name"]}`);
return response;
})
.catch(err => {
console.error(err);
throw new Error("Export operation failed");
});
};
export const scheduledFirestoreBackup = functions.pubsub
.schedule("every 24 hours")
.onRun(context => {
console.log(context);
return firestoreBackup();
});
export const callableFirestoreBackup = functions.https.onCall(
async (data, context) => {
console.log(data);
const authorized = hasAnyRole(["ADMIN"], context);
if (!context.auth || !authorized) {
console.warn(`unauthorized user${context}`);
return {
success: false,
message: "you don't have permissions to send this email",
};
} else {
await firestoreBackup();
return {
success: true,
message: "backup ran",
};
}
}
);

View File

@@ -0,0 +1,74 @@
import * as functions from "firebase-functions";
const { CloudBuildClient } = require("@google-cloud/cloudbuild");
const cb = new CloudBuildClient();
export const triggerCloudBuild = functions.https.onCall(
async (
data: {
ref: {
id: string;
path: string;
parentId: string;
};
row: any;
action: "run" | "redo" | "undo";
},
context: functions.https.CallableContext
) => {
const {
row, //ref, action
} = data;
if (!context.auth) {
return false;
}
const { triggerId, branch, projectId, groupName } = row;
// Starts a build against the branch provided.
const [resp] = await cb.runBuildTrigger({
projectId, //project hosting cloud build
triggerId,
source: {
branchName: branch,
substitutions: {
_PROJECT_ID: projectId,
_FUNCTIONS_GROUP: groupName,
},
},
});
console.info(`triggered build for ${triggerId}`);
const [build] = await resp.promise();
const STATUS_LOOKUP = [
"UNKNOWN",
"Queued",
"Working",
"Success",
"Failure",
"Error",
"Timeout",
"Cancelled",
];
for (const step of build.steps) {
console.info(
`step:\n\tname: ${step.name}\n\tstatus: ${STATUS_LOOKUP[build.status]}`
);
}
console.log(context.auth.token);
if (triggerId) {
return {
message: "cloud functions are snow flakes",
cellValue: {
redo: true,
status: `Triggered`,
// completedAt: serverTimestamp(),
meta: { ranBy: context.auth.token.email },
undo: false,
},
success: true,
};
}
return false;
}
);

View File

@@ -16,6 +16,8 @@ import synonymsFnsGenerator from "./synonyms";
import synonymsConfig from "./synonyms/config";
export { exportTable } from "./export";
export { triggerCloudBuild } from "./buildTriggers";
export { scheduledFirestoreBackup, callableFirestoreBackup } from "./backup";
import * as callableFns from "./callable";
export const callable = callableFns;
@@ -35,14 +37,20 @@ export const FT_sync = collectionSyncConfig.reduce((acc: any, collection) => {
}, {});
export const FT_snapshotSync = snapshotSyncConfig.reduce(
(acc: any, collection) => {
return {
...acc,
[`${`${`${collection.source}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}2${`${`${collection.target}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}`]: snapshotSyncFnsGenerator(collection),
};
if (collection.fnName) {
return {
...acc,
[collection.fnName]: snapshotSyncFnsGenerator(collection),
};
} else
return {
...acc,
[`${`${`${collection.source}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}2${`${`${collection.target}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}`]: snapshotSyncFnsGenerator(collection),
};
},
{}
);

View File

@@ -1,30 +0,0 @@
[
{
"source": "teams",
"onUpdate": true,
"target": "teams/{{id}}/dueDiligence",
"snapshotField": "team",
"fieldsToSync": [
"cohort",
"teamName",
"trackOutDate",
"teamMembers",
"focusArea",
"isDissolved",
"oneLineDescription",
"documents",
"longDescription",
"shortPitchDeck",
"logo",
"problem",
"model",
"geography",
"targetCustomer",
"marketSize",
"competitors",
"innovation",
"gtm",
"defensibility"
]
}
]

View File

@@ -4,38 +4,37 @@ import { db } from "../config";
import * as _ from "lodash";
import { replacer } from "../utils/email";
// returns object of fieldsToSync
enum TargetTypes {
subCollection = "subCollection",
document = "document",
}
/**
* returns object with only keys included in fieldsToSync
* @param docData
*/
const docReducer = (docData: FirebaseFirestore.DocumentData) => (
acc: any,
curr: string
) => {
if (docData[curr] !== undefined && docData[curr] !== null)
if (docData[curr] !== undefined && docData[curr] !== null) {
return { ...acc, [curr]: docData[curr] };
else return acc;
} else return acc;
};
/**
*
* @param targetCollection
* @param targetPath
* @param fieldsToSync
*/
const syncDoc = (
targetCollection: string,
const syncSubCollection = async (
targetPath: string,
snapshotField: string,
fieldsToSync: string[]
) => async (snapshot: FirebaseFirestore.DocumentSnapshot) => {
const docData = snapshot.data();
if (!docData) return false; // returns if theres no data in the doc
const syncData = fieldsToSync.reduce(docReducer(docData), {});
const collectionPath = targetCollection.replace(
/\{\{(.*?)\}\}/g,
replacer({ ...docData, id: snapshot.id })
);
if (Object.keys(syncData).length === 0) return false; // returns if theres nothing to sync
const targetDocs = await db.collection(collectionPath).get();
syncData: any,
snapshot: FirebaseFirestore.DocumentSnapshot<FirebaseFirestore.DocumentData>
) => {
const targetDocs = await db.collection(targetPath).get();
if (targetDocs.empty) return false;
for (let i = 0; i < targetDocs.docs.length; i++) {
const doc = targetDocs.docs[i];
await doc.ref.update({
@@ -48,25 +47,98 @@ const syncDoc = (
return true;
};
const syncDocSnapshot = async (
targetPath,
isArray,
snapshotField,
newSnapshotData,
snapshot
) => {
console.log({
targetPath,
isArray,
snapshotField,
});
const targetRef = db.doc(targetPath);
const targetSnapshot = await targetRef.get();
const targetData = targetSnapshot.data();
if (!targetData) {
console.warn("target does not exist");
return false;
}
if (isArray) {
const oldSnapshotsArray = targetData[snapshotField];
const snapshotDocPath = snapshot.ref.path;
const oldSnapshot = _.find(oldSnapshotsArray, { docPath: snapshotDocPath });
const updatedSnapshotsArray = oldSnapshotsArray.filter(item => {
console.log({ snapshotDocPath, item });
return item.docPath !== snapshotDocPath;
});
updatedSnapshotsArray.push({ ...oldSnapshot, snapshot: newSnapshotData });
return targetRef.update({ [snapshotField]: updatedSnapshotsArray });
} else {
return targetRef.update({
[snapshotField]: {
...targetData[snapshotField],
snapshot: newSnapshotData,
},
});
}
};
/**
* onUpdate change to snapshot adapter
* @param targetCollection
* @param fieldsToSync
*/
const syncDocOnUpdate = (
targetCollection: string,
snapshotField: string,
fieldsToSync: string[]
) => (snapshot: functions.Change<FirebaseFirestore.DocumentSnapshot>) => {
const afterData = snapshot.after.data();
const beforeData = snapshot.before.data();
const syncDocOnUpdate = (config: {
target: string;
snapshotField: string;
targetType: TargetTypes;
fieldsToSync: string[];
isArray: boolean;
}) => (snapshot: functions.Change<FirebaseFirestore.DocumentSnapshot>) => {
const { fieldsToSync, target, snapshotField, targetType, isArray } = config;
const afterDocData = snapshot.after.data();
const beforeDocData = snapshot.before.data();
if (!afterDocData || !beforeDocData) return false;
const afterData = fieldsToSync.reduce(docReducer(afterDocData), {});
const beforeData = fieldsToSync.reduce(docReducer(beforeDocData), {});
const hasChanged = !_.isEqual(afterData, beforeData);
console.log("nothing important changed");
if (Object.keys(afterData).length === 0) return false; // returns if theres nothing to sync
const targetPath = target.replace(
/\{\{(.*?)\}\}/g,
replacer({ ...snapshot.after.data(), id: snapshot.after.id })
);
console.log({ hasChanged, targetType });
if (hasChanged) {
return syncDoc(
targetCollection,
snapshotField,
fieldsToSync
)(snapshot.after);
switch (targetType) {
case TargetTypes.subCollection:
return syncSubCollection(
targetPath,
snapshotField,
afterData,
snapshot.after
);
case TargetTypes.document:
return syncDocSnapshot(
targetPath,
isArray,
snapshotField,
afterData,
snapshot.after
);
default:
return false;
}
} else {
console.warn("no change detected");
return false;
@@ -75,20 +147,14 @@ const syncDocOnUpdate = (
/**
* returns 2 different trigger functions (onCreate,onUpdate) in an object
* @param collection configuration object
* @param config configuration object
*/
const snapshotSyncFnsGenerator = collection =>
const snapshotSyncFnsGenerator = config =>
Object.entries({
onUpdate: collection.onUpdate
onUpdate: config.onUpdate
? functions.firestore
.document(`${collection.source}/{docId}`)
.onUpdate(
syncDocOnUpdate(
collection.target,
collection.snapshotField,
collection.fieldsToSync
)
)
.document(`${config.source}/{docId}`)
.onUpdate(syncDocOnUpdate(config))
: null,
}).reduce((a, [k, v]) => (v === null ? a : { ...a, [k]: v }), {});

View File

@@ -156,7 +156,7 @@ const config = [
listenerField: "cohort",
synonymField: "icPage",
transformer: (cohort, doc) => {
if (doc.icType === "IC") {
if (doc.icType === "IC" || doc.icType === "Delayed IC") {
return `https://firepage.antler.co/IC/${cohort}`;
} else return "";
},
@@ -217,9 +217,8 @@ const config = [
listenerField: "icResult",
synonymField: "isDecided",
transformer: icResult => {
if (["Yes", "No"].includes(icResult)) {
return true;
} else return false;
if (icResult && ["Yes", "No"].includes(icResult)) return true;
else return false;
},
},
],

View File

@@ -9,28 +9,31 @@ type synonymGroup = {
transformer: Function;
};
const synonyms = (docData, groups: synonymGroup[]) =>
groups.reduce((update: any, currGroup) => {
const synonyms = async (docData, groups: synonymGroup[]) => {
const updates = await groups.reduce(async (update: any, currGroup) => {
const newValue = await currGroup.transformer(
docData[currGroup.listenerField],
docData
);
if (
currGroup.isForced ||
(docData[currGroup.listenerField] &&
docData[currGroup.synonymField] !==
currGroup.transformer(docData[currGroup.listenerField], docData))
docData[currGroup.listenerField] &&
docData[currGroup.synonymField] !== newValue
) {
return {
...update,
[currGroup.synonymField]: currGroup.transformer(
docData[currGroup.listenerField],
docData
),
[currGroup.synonymField]: newValue,
};
} else return update;
}, {});
console.log({ updates });
return updates;
};
/**
*
*/
const addSynonymOnUpdate = (groups: synonymGroup[]) => (
const addSynonymOnUpdate = (groups: synonymGroup[]) => async (
change: functions.Change<FirebaseFirestore.DocumentSnapshot>
) => {
const beforeData = change.before.data();
@@ -41,6 +44,7 @@ const addSynonymOnUpdate = (groups: synonymGroup[]) => (
}
const changedGroups = groups.reduce((acc: synonymGroup[], currGroup) => {
if (
currGroup.isForced ||
beforeData[currGroup.listenerField] !== afterData[currGroup.listenerField]
) {
return [...acc, currGroup];
@@ -51,7 +55,7 @@ const addSynonymOnUpdate = (groups: synonymGroup[]) => (
if (changedGroups.length === 0) {
return false; // no changes detected
}
const updates = synonyms(
const updates = await synonyms(
{ ...afterData, id: change.after.id },
changedGroups
);
@@ -63,14 +67,14 @@ const addSynonymOnUpdate = (groups: synonymGroup[]) => (
}
};
const addSynonymOnCreate = (groups: synonymGroup[]) => (
const addSynonymOnCreate = (groups: synonymGroup[]) => async (
snapshot: FirebaseFirestore.DocumentSnapshot
) => {
const docData = snapshot.data();
if (!docData) {
return false;
}
const updates = synonyms({ ...docData, id: snapshot.id }, groups);
const updates = await synonyms({ ...docData, id: snapshot.id }, groups);
if (Object.keys(updates).length === 0) {
return false;
} else {
@@ -83,7 +87,7 @@ const addSynonymOnCreate = (groups: synonymGroup[]) => (
*
* @param collection configuration object
*/
const synonymsFnsGenerator = collection => ({
const synonymsFnsGenerator = (collection) => ({
onCreate: functions.firestore
.document(`${collection.name}/{docId}`)
.onCreate(addSynonymOnCreate(collection.groups)),

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,40 @@
steps:
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=www/.env.enc
- --plaintext-file=www/.env
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: node:10.15.1
entrypoint: yarn
args: ["install"]
dir: "www"
- name: node:10.15.1
entrypoint: yarn
args:
- env
- "${_PROJECT_ID}"
- "${_FIREBASE_WEB_API_KEY}"
- "${_ALGOLIA_APP_ID}"
- "${_ALGOLIA_APP_KEY}"
dir: "www"
- name: node:10.15.1
entrypoint: yarn
args: ["build"]
dir: "www"
- name: node:10.15.1
entrypoint: yarn
args: ["deploy"]
args:
- "target"
- "${_HOSTING_TARGET}"
- --project
- "${_PROJECT_ID}"
dir: "www"
- name: node:10.15.1
entrypoint: yarn
args:
- deploy
- --project
- "${_PROJECT_ID}"
- --debug
- --token
- "${_FIREBASE_TOKEN}"
- --only
- hosting
dir: "www"
substitutions:
_PROJECT_ID: "project-id" # default value

View File

@@ -1,29 +0,0 @@
steps:
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/.env.enc
- --plaintext-file=cloud_functions/functions/.env
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/firebase-credentials.json.enc
- --plaintext-file=cloud_functions/functions/firebase-credentials.json
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: node:10.15.1
entrypoint: yarn
args: ["install"]
dir: "cloud_functions/functions"
- name: node:10.15.1
entrypoint: yarn
args:
- "deploy"
- "functions:FT_algolia"
dir: "cloud_functions/functions"

View File

@@ -1,29 +0,0 @@
steps:
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/.env.enc
- --plaintext-file=cloud_functions/functions/.env
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/firebase-credentials.json.enc
- --plaintext-file=cloud_functions/functions/firebase-credentials.json
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: node:10.15.1
entrypoint: yarn
args: ["install"]
dir: "cloud_functions/functions"
- name: node:10.15.1
entrypoint: yarn
args:
- "deploy"
- "functions:FT_sync"
dir: "cloud_functions/functions"

View File

@@ -1,29 +0,0 @@
steps:
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/.env.enc
- --plaintext-file=cloud_functions/functions/.env
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/firebase-credentials.json.enc
- --plaintext-file=cloud_functions/functions/firebase-credentials.json
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: node:10.15.1
entrypoint: yarn
args: ["install"]
dir: "cloud_functions/functions"
- name: node:10.15.1
entrypoint: yarn
args:
- "deploy"
- "functions:FT_history"
dir: "cloud_functions/functions"

View File

@@ -1,29 +0,0 @@
steps:
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/.env.enc
- --plaintext-file=cloud_functions/functions/.env
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/firebase-credentials.json.enc
- --plaintext-file=cloud_functions/functions/firebase-credentials.json
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: node:10.15.1
entrypoint: yarn
args: ["install"]
dir: "cloud_functions/functions"
- name: node:10.15.1
entrypoint: yarn
args:
- "deploy"
- "functions:FT_permissions"
dir: "cloud_functions/functions"

View File

@@ -1,29 +0,0 @@
steps:
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/.env.enc
- --plaintext-file=cloud_functions/functions/.env
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/firebase-credentials.json.enc
- --plaintext-file=cloud_functions/functions/firebase-credentials.json
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: node:10.15.1
entrypoint: yarn
args: ["install"]
dir: "cloud_functions/functions"
- name: node:10.15.1
entrypoint: yarn
args:
- "deploy"
- "functions:FT_snapshotSync"
dir: "cloud_functions/functions"

View File

@@ -1,29 +0,0 @@
steps:
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/.env.enc
- --plaintext-file=cloud_functions/functions/.env
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/firebase-credentials.json.enc
- --plaintext-file=cloud_functions/functions/firebase-credentials.json
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: node:10.15.1
entrypoint: yarn
args: ["install"]
dir: "cloud_functions/functions"
- name: node:10.15.1
entrypoint: yarn
args:
- "deploy"
- "functions:FT_synonyms"
dir: "cloud_functions/functions"

View File

@@ -1,22 +1,4 @@
steps:
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/.env.enc
- --plaintext-file=cloud_functions/functions/.env
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: gcr.io/cloud-builders/gcloud
args:
- kms
- decrypt
- --ciphertext-file=cloud_functions/functions/firebase-credentials.json.enc
- --plaintext-file=cloud_functions/functions/firebase-credentials.json
- --location=global
- --keyring=antler-vc
- --key=cloudbuild-env
- name: node:10.15.1
entrypoint: yarn
args: ["install"]
@@ -25,5 +7,14 @@ steps:
entrypoint: yarn
args:
- "deploy"
- "functions:exportTable"
- "--project"
- "${_PROJECT_ID}"
- "--token"
- "${_FIREBASE_TOKEN}"
- "--only"
- "functions:${_FUNCTIONS_GROUP}"
dir: "cloud_functions/functions"
substitutions:
_PROJECT_ID: "project-id" # default value
_FUNCTIONS_GROUP: "exportTable" # default value

1
fireform Submodule

Submodule fireform added at dd6aa49a10

BIN
www/.env.development.enc Normal file

Binary file not shown.

View File

@@ -1,14 +0,0 @@
{
"projects": {
"default": "antler-vc"
},
"targets": {
"antler-vc": {
"hosting": {
"firetable": [
"antler-admin"
]
}
}
}
}

18
www/createDotEnv.js Normal file
View File

@@ -0,0 +1,18 @@
const fs = require("fs");
const main = (
projectID = "",
firebaseWebApiKey = "",
algoliaAppId = "",
algoliaSearhApiKey = ""
) => {
return fs.writeFileSync(
".env",
`REACT_APP_FIREBASE_PROJECT_ID = ${projectID}
REACT_APP_FIREBASE_PROJECT_WEB_API_KEY = ${firebaseWebApiKey}
REACT_APP_ALGOLIA_APP_ID = ${algoliaAppId}
REACT_APP_ALGOLIA_SEARCH_API_KEY = ${algoliaSearhApiKey}`
);
};
main(process.argv[2], process.argv[3], process.argv[4], process.argv[5]);

View File

@@ -3,10 +3,12 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@antlerengineering/components": "^0.2.7",
"@antlerengineering/multiselect": "^0.3.9",
"@date-io/date-fns": "1.x",
"@material-ui/core": "^4.9.4",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.44",
"@material-ui/lab": "^4.0.0-alpha.52",
"@material-ui/pickers": "^3.2.10",
"@mdi/js": "^4.9.95",
"@tinymce/tinymce-react": "^3.4.0",
@@ -51,6 +53,7 @@
"react-scripts": "^3.3.0",
"tinymce": "^5.2.0",
"typescript": "^3.7.2",
"use-algolia": "^1.3.0",
"use-debounce": "^3.3.0",
"use-persisted-state": "^0.3.0",
"yarn": "^1.19.0",
@@ -61,7 +64,9 @@
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"deploy": "firebase deploy --project \"$PROJECT_ID\" --debug --token \"$FIREBASE_TOKEN\" --only hosting"
"env": "node createDotEnv",
"target": "firebase target:apply hosting firetable",
"deploy": "firebase deploy"
},
"engines": {
"node": "10"

View File

@@ -47,7 +47,8 @@ a {
color: #e22729;
}
ul {
ul,
ol {
margin: 0;
padding-left: 1.5em;
}

View File

@@ -26,6 +26,9 @@ import SignOutView from "views/SignOutView";
const TableView = lazy(() =>
import("./views/TableView" /* webpackChunkName: "TableView" */)
);
const GridView = lazy(() =>
import("./views/GridView" /* webpackChunkName: "GridView" */)
);
const TablesView = lazy(() =>
import("./views/TablesView" /* webpackChunkName: "TablesView" */)
);
@@ -52,7 +55,7 @@ const App: React.FC = () => {
<PrivateRoute
exact
path={[routes.home, routes.tableWithId]}
path={[routes.home, routes.tableWithId, routes.gridWithId]}
render={() => (
<FiretableContextProvider>
<Switch>
@@ -65,6 +68,10 @@ const App: React.FC = () => {
path={routes.tableWithId}
render={() => <TableView />}
/>
<PrivateRoute
path={routes.gridWithId}
render={() => <GridView />}
/>
</Switch>
</FiretableContextProvider>
)}

View File

@@ -1,10 +1,21 @@
import React from "react";
import _merge from "lodash/merge";
import createMuiTheme from "@material-ui/core/styles/createMuiTheme";
import { createMuiTheme, ThemeOptions, fade } from "@material-ui/core/styles";
import ClearIcon from "@material-ui/icons/Clear";
const HEADING_TEXT = "Europa, sans-serif";
const BODY_TEXT = '"Open Sans", sans-serif';
export const HEADING_FONT = "Europa, sans-serif";
export const BODY_FONT = '"Open Sans", sans-serif';
export const ANTLER_RED = "#ef4747";
export const ANTLER_RED_ACCESSIBLE = "#e22729";
export const SECONDARY_GREY = "#282829";
export const SECONDARY_TEXT = "rgba(0, 0, 0, 0.6)";
export const ERROR = "#b00020";
export const ROOT_FONT_SIZE = 16;
export const toRem = (px: number) => `${px / ROOT_FONT_SIZE}rem`;
export const toEm = (px: number, root: number) => `${px / root}em`;
declare module "@material-ui/core/styles/transitions" {
interface Easing {
@@ -12,161 +23,271 @@ declare module "@material-ui/core/styles/transitions" {
}
}
const Theme = createMuiTheme({
export const themeBase = createMuiTheme({
palette: {
primary: { main: "#e22729" },
secondary: { main: "#282829" },
text: { secondary: "rgba(0, 0, 0, 0.6)" },
primary: { main: ANTLER_RED, light: ANTLER_RED },
secondary: { main: SECONDARY_GREY },
text: { secondary: SECONDARY_TEXT },
error: { main: ERROR },
},
typography: {
fontFamily: BODY_TEXT,
fontFamily: BODY_FONT,
h1: { fontFamily: HEADING_FONT },
h2: { fontFamily: HEADING_FONT },
h3: {
fontFamily: HEADING_TEXT,
fontSize: "2.25rem",
fontFamily: HEADING_FONT,
fontSize: toRem(36),
fontWeight: "bold",
fontStyle: "normal",
lineHeight: "normal",
letterSpacing: "normal",
},
h4: {
fontFamily: HEADING_TEXT,
fontFamily: HEADING_FONT,
fontSize: toRem(32),
fontWeight: "bold",
letterSpacing: 0.2,
},
h5: {
fontFamily: HEADING_TEXT,
fontSize: "1.5rem",
fontFamily: HEADING_FONT,
fontSize: toRem(24),
fontWeight: "bold",
fontStyle: "normal",
lineHeight: 1.25,
letterSpacing: "normal",
},
h6: {
fontFamily: HEADING_TEXT,
fontSize: "1.125rem",
fontFamily: HEADING_FONT,
fontSize: toRem(18),
fontWeight: "bold",
letterSpacing: 0.2,
},
overline: {
fontFamily: HEADING_TEXT,
fontSize: "0.8125rem",
fontWeight: "bold",
fontStyle: "normal",
lineHeight: 1.2,
letterSpacing: 2,
color: "rgba(0, 0, 0, 0.6)",
},
subtitle1: {
fontSize: "1rem",
lineHeight: 1.5,
letterSpacing: 0.15,
},
body1: {
lineHeight: 1.75,
letterSpacing: 0.5,
},
subtitle2: {
fontFamily: HEADING_TEXT,
fontFamily: HEADING_FONT,
fontSize: toRem(16),
fontWeight: "bold",
fontSize: "1rem",
letterSpacing: toEm(0.4, 16),
lineHeight: 1.5,
},
body1: {
letterSpacing: toEm(0.5, 16),
lineHeight: 1.75,
},
body2: {
fontSize: 13.8,
fontWeight: "normal",
lineHeight: 1.45,
letterSpacing: 0.25,
fontSize: toRem(14),
letterSpacing: toEm(0.25, 14),
},
button: {
fontFamily: HEADING_TEXT,
fontSize: "1rem",
fontFamily: HEADING_FONT,
fontSize: toRem(16),
fontWeight: "bold",
letterSpacing: toEm(0.75, 16),
lineHeight: 1,
letterSpacing: 0.75,
},
caption: {
fontFamily: HEADING_TEXT,
fontSize: "0.875rem",
fontFamily: HEADING_FONT,
fontSize: toRem(13),
fontWeight: "bold",
letterSpacing: 0.25,
lineHeight: 1.2,
letterSpacing: toEm(0.4, 13),
lineHeight: 16 / 13,
},
overline: {
fontFamily: HEADING_FONT,
fontSize: toRem(13),
fontWeight: "bold",
letterSpacing: toEm(2, 13),
lineHeight: 16 / 13,
color: SECONDARY_TEXT,
},
},
});
export const defaultOverrides: ThemeOptions = {
transitions: {
easing: {
custom: "cubic-bezier(0.25, 0.1, 0.25, 1)",
},
easing: { custom: "cubic-bezier(0.25, 0.1, 0.25, 1)" },
},
overrides: {
MuiChip: {
MuiContainer: {
root: {
borderRadius: 4,
},
outlined: {
backgroundColor: "rgba(0, 0, 0, 0.08)",
borderColor: "rgba(0, 0, 0, 0.08)",
color: "rgba(0, 0, 0, 0.6)",
},
label: {
// overline style
fontFamily: HEADING_TEXT,
fontSize: 13.4,
fontWeight: "bold",
fontStyle: "normal",
lineHeight: 1.2,
letterSpacing: 1.125,
},
labelSmall: {
paddingLeft: 10,
paddingRight: 9,
},
deleteIcon: {
color: "inherit",
},
},
MuiPaper: {
rounded: {
borderRadius: 8,
},
},
MuiFormLabel: {
root: {
fontFamily: HEADING_TEXT,
fontSize: "1rem",
fontWeight: "bold",
letterSpacing: 0.4,
"@supports (padding: max(0px))": {
paddingLeft: `max(${themeBase.spacing(
2
)}px, env(safe-area-inset-left))`,
paddingRight: `max(${themeBase.spacing(
2
)}px, env(safe-area-inset-right))`,
"@media (min-width: 640px)": {
paddingLeft: `max(${themeBase.spacing(
3
)}px, env(safe-area-inset-left))`,
paddingRight: `max(${themeBase.spacing(
3
)}px, env(safe-area-inset-right))`,
},
},
},
},
MuiTooltip: {
tooltip: {
fontFamily: HEADING_TEXT,
fontSize: "0.8125rem",
fontWeight: "bold",
letterSpacing: 0.4,
lineHeight: 1.2,
},
},
MuiTab: {
root: { fontSize: "1rem !important" },
tooltip: themeBase.typography.caption,
},
MuiButton: {
root: { minHeight: 36 },
sizeSmall: { minHeight: 30 },
sizeLarge: { minHeight: 48 },
contained: {
borderRadius: 500,
minHeight: 32,
boxShadow: "none",
},
containedSizeLarge: {
padding: "8px 32px",
minHeight: 48,
padding: themeBase.spacing(1, 4),
},
outlinedPrimary: {
// Same as outlined text field
borderColor: "rgba(0, 0, 0, 0.23)",
},
outlinedSizeLarge: {
padding: themeBase.spacing(1, 4),
borderRadius: 500,
"&$outlinedPrimary": { borderColor: ANTLER_RED },
},
},
MuiSvgIcon: {
fontSizeLarge: { fontSize: toRem(36) },
},
// Override text field label
MuiFormLabel: {
root: {
...themeBase.typography.subtitle2,
lineHeight: 1,
},
},
// Override radio & checkbox labels
MuiFormControlLabel: {
root: { display: "flex" },
label: themeBase.typography.body1,
},
MuiChip: {
root: {
borderRadius: 4,
maxWidth: "100%",
height: "auto",
minHeight: 32,
color: themeBase.palette.text.secondary,
},
label: {
...themeBase.typography.caption,
color: "inherit",
padding: themeBase.spacing(1, 1.5),
// whiteSpace: "normal",
"$outlined &": {
paddingTop: themeBase.spacing(0.875),
paddingBottom: themeBase.spacing(0.875),
},
},
sizeSmall: { minHeight: 24 },
labelSmall: {
padding: themeBase.spacing(0.5, 1.5),
},
outlined: {
backgroundColor: themeBase.palette.action.selected,
borderColor: themeBase.palette.action.selected,
},
outlinedPrimary: {
backgroundColor: fade(
ANTLER_RED,
themeBase.palette.action.selectedOpacity
),
},
deleteIcon: { color: "inherit" },
},
MuiBadge: {
badge: {
...themeBase.typography.caption,
fontFeatureSettings: '"tnum"',
},
},
MuiPaper: {
rounded: { borderRadius: 8 },
},
MuiSlider: {
disabled: {},
rail: {
backgroundColor: "#e7e7e7",
opacity: 1,
},
mark: {
width: 4,
height: 4,
borderRadius: "50%",
marginLeft: -2,
marginTop: -1,
backgroundColor: "#69696a",
"$disabled &": { backgroundColor: "currentColor" },
},
markActive: {
opacity: 1,
backgroundColor: "currentColor",
"$disabled &": { backgroundColor: "currentColor" },
},
thumb: {
width: 16,
height: 16,
marginTop: -7,
marginLeft: -8,
"$disabled &": {
width: 12,
height: 12,
marginTop: -5,
marginLeft: -6,
},
},
valueLabel: {
top: -22,
...themeBase.typography.caption,
color: themeBase.palette.primary.main,
"& > *": {
width: "auto",
minWidth: 24,
height: 24,
whiteSpace: "nowrap",
borderRadius: 500,
padding: themeBase.spacing(0, 1),
paddingRight: themeBase.spacing(0.875),
},
"& *": { transform: "none" },
},
markLabel: themeBase.typography.caption,
},
MuiLinearProgress: {
colorPrimary: { backgroundColor: "#e7e7e7" },
colorSecondary: { backgroundColor: "#e7e7e7" },
},
},
props: {
MuiTypography: {
variantMapping: {
subtitle1: "div",
subtitle2: "div",
},
},
MuiRadio: { color: "primary" },
MuiCheckbox: { color: "primary" },
MuiButton: { color: "primary" },
MuiTabs: {
indicatorColor: "primary",
textColor: "primary",
},
MuiCircularProgress: { size: 44 },
// Select: show dropdown below text field to follow new Material spec
MuiSelect: {
@@ -176,13 +297,17 @@ const Theme = createMuiTheme({
transformOrigin: { vertical: "top", horizontal: "center" },
},
},
MuiLink: {
color: "primary",
underline: "hover",
},
MuiChip: {
size: "medium",
variant: "outlined",
deleteIcon: <ClearIcon />,
},
MuiButton: { color: "primary" },
MuiTextField: { variant: "filled" },
},
});
};
export const Theme = createMuiTheme(_merge(themeBase, defaultOverrides));
export default Theme;

View File

@@ -33,6 +33,10 @@ export interface IConnectTableSelectProps {
TextFieldProps?: Partial<TextFieldProps>;
}
/**
* TODO: Update this to use @antlerengineering/multiselect
* This is a copy-paste of the old MultiSelect
*/
export default function ConnectTableSelect({
value = [],
className,

View File

@@ -3,7 +3,7 @@ import _camelCase from "lodash/camelCase";
import AddIcon from "@material-ui/icons/Add";
import useRouter from "../hooks/useRouter";
import MultiSelect from "../components/MultiSelect";
import MultiSelect from "@antlerengineering/multiselect";
import {
Tooltip,
Fab,

View File

@@ -2,6 +2,8 @@ import React from "react";
import clsx from "clsx";
import { makeStyles, createStyles, Chip, ChipProps } from "@material-ui/core";
export const VARIANTS = ["yes", "no", "maybe"];
const useStyles = makeStyles(
createStyles({
yes: {
@@ -23,11 +25,10 @@ const useStyles = makeStyles(
export default function FormattedChip(props: ChipProps) {
const classes = useStyles();
const variants = ["yes", "no", "maybe"];
const label =
typeof props.label === "string" ? props.label.toLowerCase() : "";
if (variants.includes(label)) {
if (VARIANTS.includes(label)) {
return (
<Chip {...props} className={clsx(props.className, classes[label])} />
);

View File

@@ -0,0 +1,237 @@
import React, { useState, useEffect } from "react";
import { SearchIndex } from "algoliasearch/lite";
import { FacetHit } from "@algolia/client-search";
import useAlgolia from "use-algolia";
import { useDebouncedCallback } from "use-debounce";
import {
makeStyles,
createStyles,
Grid,
Typography,
Button,
TextField,
InputAdornment,
ListItemSecondaryAction,
} from "@material-ui/core";
import SearchIcon from "@material-ui/icons/Search";
import MultiSelect from "@antlerengineering/multiselect";
const useStyles = makeStyles(theme =>
createStyles({
resetFilters: { marginRight: -theme.spacing(1) },
filterGrid: {
marginTop: 0,
marginBottom: theme.spacing(3),
},
listItemText: { whiteSpace: "pre-line" },
count: {
position: "static",
marginLeft: "auto",
paddingLeft: theme.spacing(1.5),
transform: "none",
color: theme.palette.text.disabled,
},
})
);
/**
* Generates the string to dispatch as filters for the query
* @param filterValues The user-selected filters
* @param requiredFilters Filters not selected by the user
*/
const generateFiltersString = (
filterValues: Record<string, string[]>,
requiredFilters?: string
) => {
if (Object.keys(filterValues).length === 0) return null;
let filtersString = Object.entries(filterValues)
.filter(([, values]) => values.length > 0)
.map(
([facet, values]) =>
`(${values
.map(value => `${facet}:"${value.replace(/"/g, '\\"')}"`)
.join(" OR ")})`
)
.join(" AND ");
if (requiredFilters) {
if (filtersString)
filtersString = requiredFilters + " AND " + filtersString;
else filtersString = requiredFilters;
}
return filtersString;
};
export interface IAlgoliaFiltersProps {
index: SearchIndex;
request: ReturnType<typeof useAlgolia>[0]["request"];
requestDispatch: ReturnType<typeof useAlgolia>[1];
requiredFilters?: string;
label: string;
filters: {
label: string;
facet: string;
labelTransformer?: (value: string) => string;
}[];
search?: boolean;
}
export default function AlgoliaFilters({
index,
request,
requestDispatch,
requiredFilters,
label,
filters,
search = true,
}: IAlgoliaFiltersProps) {
const classes = useStyles();
// Store filter values
const [filterValues, setFilterValues] = useState<Record<string, string[]>>(
{}
);
// Push filter values to dispatch
useEffect(() => {
const filtersString = generateFiltersString(filterValues, requiredFilters);
if (filtersString === null) return;
requestDispatch({ filters: filtersString });
}, [filterValues]);
// Store facet values
const [facetValues, setFacetValues] = useState<
Record<string, readonly FacetHit[]>
>({});
// Get facet values
useEffect(() => {
if (!index) return;
filters.forEach(filter => {
const params = { ...request, maxFacetHits: 100 };
// Ignore current user-selected value for these filters so all options
// continue to show up
params.filters =
generateFiltersString(
{ ...filterValues, [filter.facet]: [] },
requiredFilters
) ?? "";
index
.searchForFacetValues(filter.facet, "", params)
.then(({ facetHits }) =>
setFacetValues(other => ({ ...other, [filter.facet]: facetHits }))
);
});
}, [filters, index, filterValues, requiredFilters]);
// Reset filters
const handleResetFilters = () => {
setFilterValues({});
setQuery("");
requestDispatch({ filters: requiredFilters ?? "", query: "" });
};
// Store search query
const [query, setQuery] = useState("");
const [handleQueryChange] = useDebouncedCallback(
(query: string) => requestDispatch({ query }),
500
);
return (
<div>
<Grid container spacing={1} alignItems="center">
<Grid item xs>
<Typography variant="overline">
Filter{label ? " " + label : "s"}
</Typography>
</Grid>
<Grid item>
<Button
color="primary"
onClick={handleResetFilters}
className={classes.resetFilters}
disabled={query === "" && Object.keys(filterValues).length === 0}
>
Reset Filters
</Button>
</Grid>
</Grid>
<Grid
container
spacing={2}
alignItems="center"
className={classes.filterGrid}
>
{search && (
<Grid item xs={12} md={4} lg={3}>
<TextField
value={query}
onChange={e => {
setQuery(e.target.value);
handleQueryChange(e.target.value);
}}
variant="filled"
type="search"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
aria-label={`Search${label ? " " + label : ""}`}
placeholder={`Search${label ? " " + label : ""}`}
hiddenLabel
fullWidth
/>
</Grid>
)}
{filters.map(filter => (
<Grid item key={filter.facet} xs={12} sm={6} md={4} lg={3}>
<MultiSelect
label={filter.label}
value={filterValues[filter.facet] ?? []}
onChange={value =>
setFilterValues(other => ({ ...other, [filter.facet]: value }))
}
options={
facetValues[filter.facet]?.map(item => ({
value: item.value,
label: filter.labelTransformer
? filter.labelTransformer(item.value)
: item.value,
count: item.count,
})) ?? []
}
itemRenderer={option => (
<React.Fragment key={option.value}>
{option.label}
<ListItemSecondaryAction className={classes.count}>
<Typography
variant="body2"
color="inherit"
component="span"
>
{(option as any).count}
</Typography>
</ListItemSecondaryAction>
</React.Fragment>
)}
searchable={facetValues[filter.facet]?.length > 10}
/>
</Grid>
))}
</Grid>
</div>
);
}

View File

@@ -0,0 +1,249 @@
import React, { useState } from "react";
import clsx from "clsx";
import {
Card,
Grid,
Typography,
Button,
CardActions,
CardContent,
CardMedia,
Divider,
Tabs,
Tab,
} from "@material-ui/core";
import { ButtonProps } from "@material-ui/core/Button";
import { GoIcon } from "@antlerengineering/components";
import useStyles from "./styles";
export interface ICardProps {
className?: string;
style?: React.CSSProperties;
overline?: React.ReactNode;
title?: React.ReactNode;
imageSource?: string;
imageShape?: "square" | "circle";
imageClassName?: string;
tabs?: {
label: string;
content: React.ReactNode;
disabled?: boolean;
}[];
bodyContent?: React.ReactNode;
primaryButton?: { label: string } & Partial<ButtonProps>;
primaryLink?: {
href?: string;
target?: string;
rel?: string;
label: string;
} & Partial<ButtonProps>;
secondaryAction?: React.ReactNode;
}
const a11yProps = (index: number) => ({
id: `full-width-tab-${index}`,
"aria-controls": `full-width-tabpanel-${index}`,
});
export default function BasicCard({
className,
style,
overline,
title,
imageSource,
imageShape = "square",
imageClassName,
tabs,
bodyContent,
primaryButton,
primaryLink,
secondaryAction,
}: ICardProps) {
const classes = useStyles();
const [tab, setTab] = useState(0);
const handleChangeTab = (event: React.ChangeEvent<{}>, newValue: number) =>
setTab(newValue);
return (
<Card className={clsx(classes.root, className)} style={style}>
<Grid
container
direction="column"
wrap="nowrap"
className={classes.container}
>
<Grid item xs className={classes.cardContentContainer}>
<CardContent className={clsx(classes.container, classes.cardContent)}>
<Grid
container
direction="column"
wrap="nowrap"
className={classes.container}
>
{(overline || title || imageSource) && (
<Grid item className={classes.headerContainer}>
<Grid container spacing={3}>
<Grid item xs>
{overline && (
<Typography
variant="overline"
className={classes.overline}
>
{overline}
</Typography>
)}
{title && (
<Typography variant="h5" className={classes.title}>
{title}
</Typography>
)}
</Grid>
{imageSource && (
<Grid item>
<CardMedia
className={clsx(
classes.image,
imageShape === "circle" && classes.imageCircle,
imageClassName
)}
image={imageSource}
title={typeof title === "string" ? title : ""}
/>
</Grid>
)}
</Grid>
</Grid>
)}
{tabs && (
<Grid item className={classes.tabsContainer}>
<Tabs
className={classes.tabs}
value={tab}
onChange={handleChangeTab}
indicatorColor="primary"
textColor="primary"
variant="fullWidth"
aria-label="full width tabs"
>
{tabs?.map((tab, index) => (
<Tab
key={`card-tab-${index}`}
className={classes.tab}
label={tab.label}
disabled={tab.disabled}
{...a11yProps(index)}
/>
))}
</Tabs>
<Divider className={clsx(classes.tabs, classes.tabDivider)} />
</Grid>
)}
{(tabs || bodyContent) && (
<Grid item xs className={classes.contentContainer}>
{tabs && (
<div className={classes.tabSection}>
{tabs[tab].content && Array.isArray(tabs[tab].content) ? (
<Grid
container
direction="column"
wrap="nowrap"
justify="space-between"
spacing={3}
className={classes.tabContentGrid}
>
{(tabs[tab].content as React.ReactNode[]).map(
(element, index) => (
<Grid item key={`tab-content-${index}`}>
{element}
</Grid>
)
)}
</Grid>
) : (
tabs[tab].content
)}
</div>
)}
{bodyContent && Array.isArray(bodyContent) ? (
<Grid
container
direction="column"
wrap="nowrap"
justify="space-between"
className={classes.container}
>
{bodyContent.map((element, i) => (
<Grid item key={i}>
{element}
</Grid>
))}
</Grid>
) : (
bodyContent
)}
</Grid>
)}
</Grid>
</CardContent>
</Grid>
{(primaryButton || primaryLink || secondaryAction) && (
<Grid item>
<Divider className={classes.divider} />
<CardActions className={classes.cardActions}>
<Grid item>
{primaryButton && (
<Button
{...primaryButton}
color={primaryButton.color || "primary"}
disabled={!!primaryButton.disabled}
endIcon={
primaryButton.endIcon === undefined ? (
<GoIcon />
) : (
primaryButton.endIcon
)
}
>
{primaryButton.label}
</Button>
)}
{primaryLink && (
<Button
classes={{ label: classes.primaryLinkLabel }}
{...(primaryLink as any)}
color={primaryLink.color || "primary"}
component="a"
endIcon={
primaryLink.endIcon === undefined ? (
<GoIcon />
) : (
primaryLink.endIcon
)
}
>
{primaryLink.label}
</Button>
)}
</Grid>
{secondaryAction && <Grid item>{secondaryAction}</Grid>}
</CardActions>
</Grid>
)}
</Grid>
</Card>
);
}

View File

@@ -0,0 +1,58 @@
import { makeStyles, createStyles } from "@material-ui/core/styles";
const useStyles = makeStyles(theme =>
createStyles({
root: { width: "100%", height: "100%" },
container: { height: "100%" },
cardContentContainer: {
"&:last-child": { paddingBottom: theme.spacing(3) },
},
cardContent: {
"&:last-child": { paddingBottom: 0 },
},
headerContainer: {},
tabsContainer: { "$headerContainer + &": { marginTop: theme.spacing(2) } },
contentContainer: {
"$headerContainer + &": { marginTop: theme.spacing(2) },
},
overline: {
marginBottom: theme.spacing(3),
color: theme.palette.text.disabled,
wordBreak: "break-word",
},
title: {
whiteSpace: "pre-line",
wordBreak: "break-word",
},
image: {
width: 80,
height: 80,
borderRadius: theme.shape.borderRadius,
},
imageCircle: { borderRadius: "50%" },
tabs: { margin: theme.spacing(0, -2) },
tab: { minWidth: 0 },
tabDivider: { marginTop: -1 },
tabSection: { paddingTop: theme.spacing(2), height: "100%" },
tabContentGrid: { height: `calc(100% + ${theme.spacing(3)}px)` },
divider: {
margin: theme.spacing(2),
marginBottom: 0,
},
cardActions: {
padding: theme.spacing(0.75, 1),
display: "flex",
justifyContent: "space-between",
},
primaryLinkLabel: { whiteSpace: "nowrap" },
})
);
export default useStyles;

View File

@@ -0,0 +1,83 @@
import React from "react";
import { Grid as MuiGrid } from "@material-ui/core";
import Card from "./Card";
import useAlgolia from "use-algolia";
import AlgoliaFilters from "./AlgoliaFilters";
import _get from "lodash/get";
const advisorsFilters = [
{ label: "Type", facet: "type" },
{ label: "Experience (Industry)", facet: "expertise" },
{ label: "Location", facet: "location" },
];
interface IGridProps {
collection: string;
filters: any[];
}
export const replacer = (data: any) => (m: string, key: string) => {
const objKey = key.split(":")[0];
const defaultValue = key.split(":")[1] || "";
return _get(data, objKey, defaultValue);
};
const CARD_CONFIG = {
title: `{{firstName}} {{lastName}}`,
image: "{{profilePhoto[0].downloadURL}}",
body: "{{bio}}",
};
export default function Grid({ collection, filters }: IGridProps) {
const [algoliaState, requestDispatch, , setAlgoliaConfig] = useAlgolia(
process.env.REACT_APP_ALGOLIA_APP_ID!,
process.env.REACT_APP_ALGOLIA_SEARCH_API_KEY!,
collection,
{ hitsPerPage: 100 }
);
const isLoading = algoliaState.loading || !algoliaState.index;
const noResults = algoliaState.hits.length === 0;
const requiredFilters = ``;
const isEmpty =
noResults &&
algoliaState.request?.query === undefined &&
algoliaState.request?.filters === requiredFilters;
return (
<>
{algoliaState.index && !isEmpty && (
<AlgoliaFilters
index={algoliaState.index}
request={algoliaState.request}
requestDispatch={requestDispatch}
requiredFilters={requiredFilters}
label={collection}
filters={[]}
search
/>
)}
<MuiGrid container spacing={4}>
{" "}
{algoliaState.hits.map(hit => {
return (
<MuiGrid key={hit.objectID} item lg={4} md={6} xs={12}>
{" "}
<Card
bodyContent={CARD_CONFIG.body.replace(
/\{\{(.*?)\}\}/g,
replacer(hit)
)}
title={CARD_CONFIG.title.replace(
/\{\{(.*?)\}\}/g,
replacer(hit)
)}
imageSource={CARD_CONFIG.image.replace(
/\{\{(.*?)\}\}/g,
replacer(hit)
)}
/>{" "}
</MuiGrid>
);
})}
</MuiGrid>
</>
);
}

View File

@@ -1,240 +0,0 @@
import React, { useState } from "react";
import clsx from "clsx";
import {
TextField,
Chip,
Grid,
Button,
Typography,
IconButton,
InputAdornment,
Portal,
} from "@material-ui/core";
import SearchIcon from "@material-ui/icons/Search";
import SelectedIcon from "@material-ui/icons/Check";
import RadioButtonUncheckedIcon from "@material-ui/icons/RadioButtonUnchecked";
import RadioButtonCheckedIcon from "@material-ui/icons/RadioButtonChecked";
import AddIcon from "@material-ui/icons/Add";
import useStyles from "./styles";
import { IMultiSelectProps, OptionType } from ".";
export interface IPopupContentsProps
extends Omit<
IMultiSelectProps,
"options" | "label" | "className" | "TextFieldProps"
> {
options: OptionType[];
dropdownWidth: number;
setDropdownWidth: React.Dispatch<React.SetStateAction<number>>;
}
/**
* The contents of the popup of the MultiSelect dropdown.
* Broken out into separate component since the effects and functions here
* are quite heavy, so they will only be mounted/executed when the dropdown
* is open.
*/
export default function PopupContents({
onChange,
value = [],
searchable = true,
itemRenderer,
freeText = false,
multiple = true,
selectAll = true,
options,
dropdownWidth,
setDropdownWidth,
}: IPopupContentsProps) {
const classes = useStyles({
searchable,
freeText,
multiple,
width: dropdownWidth,
});
const [filterState, setFilterState] = useState("");
const select = option => {
if (multiple) onChange([...value, option.value]);
else onChange([option.value]);
};
const deselect = (option: { label: string; value: string }) => {
if (multiple) onChange(value?.filter(v => v !== option.value));
else onChange([]);
};
const handleSelectAll = () => onChange(options.map(o => o.value));
const clearSelection = () => onChange([]);
// `freeText`: Handle custom field
const [customField, setCustomField] = useState("");
const handleAddCustom = () => {
setCustomField("");
// Prevent duplicate being added
if (value.includes(customField)) return;
select({ value: customField, label: customField });
};
// Get longest item label
const longestLabel = options.reduce(
(acc, curr) => (curr.label?.length > acc.length ? curr.label : acc),
""
);
return (
<Grid container direction="column">
{searchable && (
<Grid item className={classes.searchRow}>
<TextField
value={filterState}
onChange={e => setFilterState(e.target.value)}
fullWidth
variant="filled"
margin="dense"
label="Search items"
className={classes.noMargins}
InputProps={{
//disableUnderline: true,
endAdornment: (
<InputAdornment position="end">
<SearchIcon />
</InputAdornment>
),
}}
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
/>
</Grid>
)}
<Grid item xs className={classes.chipListRow}>
<ul className={classes.chipList}>
{options
.filter(option => {
return (
filterState === "" ||
option.label.toUpperCase().includes(filterState.toUpperCase())
);
})
.map(option => {
const isSelected = value?.includes(option.value);
let icon = <></>;
if (multiple) {
if (isSelected) icon = <SelectedIcon />;
} else {
if (isSelected) icon = <RadioButtonCheckedIcon />;
else icon = <RadioButtonUncheckedIcon />;
}
if (itemRenderer)
return itemRenderer(option, select, deselect, isSelected);
else
return (
<Chip
key={`select-chip-${option.value}`}
className={clsx(
classes.chip,
isSelected && classes.selectedChip
)}
onClick={e => {
e.stopPropagation();
if (isSelected) deselect(option);
else select(option);
}}
icon={icon}
label={option.label}
variant="outlined"
component="li"
size="medium"
/>
);
})}
</ul>
</Grid>
{freeText && (
<Grid item className={classes.footerRow}>
<Grid container alignItems="center" spacing={1}>
<Grid item>
<IconButton
onClick={handleAddCustom}
className={classes.addCustomButton}
>
<AddIcon />
</IconButton>
</Grid>
<Grid item xs>
<TextField
fullWidth
variant="filled"
label="Add new item"
margin="dense"
className={classes.noMargins}
value={customField}
onChange={e => setCustomField(e.target.value)}
onKeyPress={e => {
if (e.key === "Enter") handleAddCustom();
}}
onKeyDown={e => e.stopPropagation()}
/>
</Grid>
</Grid>
</Grid>
)}
{multiple && (
<Grid item className={clsx(classes.footerRow, classes.selectedRow)}>
<Grid
container
direction="row"
justify="space-between"
alignItems="center"
>
<Typography
variant="button"
color="textSecondary"
className={classes.selectedNum}
>
{value?.length} of {options.length}
</Typography>
<Button
disabled={selectAll === false && value?.length === 0}
onClick={
value?.length === options.length || !selectAll
? clearSelection
: handleSelectAll
}
color="primary"
className={classes.selectAllButton}
>
{value?.length === options.length || !selectAll
? "Clear Selection"
: "Select All"}
</Button>
</Grid>
</Grid>
)}
<Portal>
<Chip
className={clsx(classes.chip, classes.measureChip)}
icon={<SelectedIcon />}
label={longestLabel}
variant="outlined"
role="presentation"
ref={el => {
if (!el) return;
const width = el.getBoundingClientRect().width;
if (dropdownWidth < width) setDropdownWidth(width + 32);
}}
/>
</Portal>
</Grid>
);
}

View File

@@ -1,128 +0,0 @@
import React, { useState } from "react";
import clsx from "clsx";
import _unionWith from "lodash/unionWith";
import _find from "lodash/find";
import { TextField, TextFieldProps } from "@material-ui/core";
import useStyles from "./styles";
import PopupContents from "./PopupContents";
export type OptionType = { label: string; value: string; data?: any };
export interface IMultiSelectProps {
label: string;
value: string[];
editable?: boolean;
/** The list of options to display. Passing `string[]` will auto-transform */
options: OptionType[] | string[];
itemRenderer?: (
option: OptionType,
select: Function,
deselect: Function,
isSelected: Boolean
) => React.ReactNode;
searchable?: boolean;
onChange: (value: string[]) => void;
/** Optionally allow the user to select all options */
selectAll?: boolean;
/** Optionally allow the user to add a custom option */
freeText?: boolean;
/** Optionally set this prop to `false` to only allow one option */
multiple?: boolean;
/** Optional style overrides for root MUI `TextField` component */
className?: string;
/** Override any props of the root MUI `TextField` component */
TextFieldProps?: Partial<TextFieldProps>;
}
export default function MultiSelect({
options: optionsProp,
label,
className,
TextFieldProps = {},
...props
}: IMultiSelectProps) {
const {
value = [],
searchable = true,
freeText = false,
multiple = true,
} = props;
const [dropdownWidth, setDropdownWidth] = useState(200);
const classes = useStyles({
searchable,
freeText,
multiple,
width: dropdownWidth,
});
const sanitisedValue = value.filter(v => v?.length > 0);
// Transform `option` prop if its just strings
let options =
typeof optionsProp[0] === "string"
? (optionsProp as string[]).map(
item => ({ label: item, value: item } as OptionType)
)
: (optionsProp as OptionType[]);
// If `freeText` enabled, show the users custom fields
if (freeText) {
// `value` prop is an array of all values. It removes labels
const formattedValues = sanitisedValue?.map(x => ({ label: x, value: x }));
options = _unionWith(
options,
formattedValues,
(a, b) => a.value === b.value
);
}
return (
<TextField
label={label}
variant={"filled" as any}
select
value={sanitisedValue}
className={clsx(classes.root, className)}
{...TextFieldProps}
SelectProps={{
renderValue: value => {
const selected = value as string[];
if (selected.length === 1 && typeof selected[0] === "string") {
const selectedOption = _find(options, { value: selected[0] });
return selectedOption?.label;
}
return `${selected.length} of ${options.length} selected`;
},
displayEmpty: true,
classes: { root: classes.selectRoot },
...TextFieldProps.SelectProps,
// Must have this set to prevent MUI transforming `value`
// prop for this component to a comma-separated string
multiple: true,
MenuProps: {
classes: { paper: classes.paper, list: classes.menuChild },
MenuListProps: { disablePadding: true },
getContentAnchorEl: null,
anchorOrigin: { vertical: "bottom", horizontal: "center" },
transformOrigin: { vertical: "top", horizontal: "center" },
...TextFieldProps.SelectProps?.MenuProps,
},
}}
ref={el => {
if (!el) return;
const width = el.getBoundingClientRect().width;
if (dropdownWidth < width) setDropdownWidth(width);
}}
>
<div className={classes.popupContentsWrapper}>
<PopupContents
{...props}
options={options}
dropdownWidth={dropdownWidth}
setDropdownWidth={setDropdownWidth}
/>
</div>
</TextField>
);
}

View File

@@ -1,99 +0,0 @@
import { makeStyles, createStyles } from "@material-ui/core";
interface StylesProps {
searchable: boolean;
freeText: boolean;
multiple: boolean;
width?: number;
}
export const useStyles = makeStyles(theme =>
createStyles({
root: { minWidth: 200 },
selectRoot: { paddingRight: theme.spacing(4) },
paper: { overflow: "hidden", maxHeight: "calc(100% - 48px)" },
popupContentsWrapper: { outline: 0 },
menuChild: {
padding: `0 ${theme.spacing(2)}px`,
width: ({ width }: StylesProps) => width || 480,
maxWidth: `calc(100vw - ${theme.spacing(4)}px)`,
minWidth: 300,
},
noMargins: { margin: 0 },
searchRow: { marginTop: theme.spacing(2) },
chipListRow: {
background: `${theme.palette.background.paper} no-repeat`,
backgroundImage:
"linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,0)), linear-gradient(to top, rgba(255,255,255,1), rgba(255,255,255,0))",
backgroundPosition: `-12px 0, -24px 100%`,
backgroundSize: `calc(100% + 12px + 12px) 16px`,
position: "relative",
"&::before, &::after": {
content: '""',
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 9,
display: "block",
height: 16,
background: `linear-gradient(to bottom, #fff, rgba(255, 255, 255, 0))`,
},
"&::after": {
top: "auto",
bottom: 0,
background: `linear-gradient(to top, #fff, rgba(255, 255, 255, 0))`,
},
},
chipList: ({ searchable, freeText, multiple }: StylesProps) => {
let maxHeightDeductions = 0;
if (searchable) maxHeightDeductions -= 64;
if (multiple) maxHeightDeductions -= 48;
if (freeText) maxHeightDeductions -= 48;
if (freeText && !multiple) maxHeightDeductions -= theme.spacing(2);
return {
margin: `0px -${theme.spacing(2)}px`,
padding: `12px ${theme.spacing(2) - theme.spacing(0.5)}px`,
overflowY: "auto" as "auto",
maxHeight: `calc(100vh - 48px - ${-maxHeightDeductions}px)`,
};
},
chip: {
margin: theme.spacing(0.5),
// Allow multi-line chip
maxWidth: `calc(100% - ${theme.spacing(1)}px)`,
},
selectedChip: { backgroundColor: theme.palette.divider },
footerRow: { marginBottom: theme.spacing(2) },
addCustomButton: { marginLeft: -theme.spacing(1) },
selectedRow: {
"$chipListRow + &": { marginTop: -theme.spacing(1) },
"$footerRow + &": { marginTop: -theme.spacing(2) },
marginBottom: 0,
"& > div": { height: 48 },
},
selectAllButton: { marginRight: -theme.spacing(1) },
selectedNum: { fontFeatureSettings: '"tnum"' },
measureChip: {
visibility: "hidden",
position: "absolute",
top: 0,
left: 0,
},
})
);
export default useStyles;

View File

@@ -32,7 +32,7 @@ const useStyles = makeStyles(theme =>
"&:hover": { color: theme.palette.primary.dark },
},
"& ul": {
"& ul, & ol": {
margin: 0,
paddingLeft: "1.5em",
},

View File

@@ -18,7 +18,7 @@ export default function DatePicker(props: KeyboardDatePickerProps) {
transformedValue = props.field.value;
const handleChange = (date: Date | null) => {
if (!date || isNaN(date.valueOf())) return;
if (isNaN(date?.valueOf() ?? 0)) return;
props.form.setFieldValue(props.field.name, date);
};

View File

@@ -20,7 +20,7 @@ export default function DateTimePicker(props: KeyboardDateTimePickerProps) {
transformedValue = props.field.value;
const handleChange = (date: Date | null) => {
if (!date || isNaN(date.valueOf())) return;
if (isNaN(date?.valueOf() ?? 0)) return;
props.form.setFieldValue(props.field.name, date);
};

View File

@@ -3,9 +3,7 @@ import { FieldProps } from "formik";
import { useTheme, Grid } from "@material-ui/core";
import MultiSelect_, {
IMultiSelectProps as IMultiSelectProps_,
} from "components/MultiSelect";
import MultiSelect_, { MultiSelectProps } from "@antlerengineering/multiselect";
import FormattedChip from "components/FormattedChip";
export default function MultiSelect({
@@ -13,22 +11,24 @@ export default function MultiSelect({
form,
editable,
...props
}: FieldProps<string[]> & IMultiSelectProps_) {
}: FieldProps<string[]> & MultiSelectProps<string> & { editable?: boolean }) {
const theme = useTheme();
const handleDelete = (index: number) => () => {
const newValues = [...field.value];
newValues.splice(index, 1);
form.setFieldValue(field.name, newValues);
};
return (
<>
<MultiSelect_
{...props}
multiple
value={field.value}
onChange={value => form.setFieldValue(field.name, value)}
disabled={editable === false}
TextFieldProps={{
disabled: editable === false,
fullWidth: true,
label: "",
hiddenLabel: true,
error: !!(form.touched[field.name] && form.errors[field.name]),

View File

@@ -3,7 +3,7 @@ import { FieldProps } from "formik";
import { useTheme } from "@material-ui/core";
import MultiSelect, { IMultiSelectProps } from "components/MultiSelect";
import MultiSelect, { MultiSelectProps } from "@antlerengineering/multiselect";
import FormattedChip from "components/FormattedChip";
/**
@@ -13,22 +13,22 @@ import FormattedChip from "components/FormattedChip";
export default function SingleSelect({
field,
form,
editable,
...props
}: FieldProps<string[]> & IMultiSelectProps) {
}: FieldProps<string> & MultiSelectProps<string> & { editable: boolean }) {
const theme = useTheme();
const value = ([field.value] as unknown) as string[];
const handleChange = value =>
form.setFieldValue(field.name, value.join(", "));
const handleChange = value => form.setFieldValue(field.name, value);
return (
<>
<MultiSelect
{...props}
value={value}
multiple={false}
value={field.value}
onChange={handleChange}
disabled={editable === false}
TextFieldProps={{
fullWidth: true,
label: "",
hiddenLabel: true,
error: !!(form.touched[field.name] && form.errors[field.name]),
@@ -37,8 +37,7 @@ export default function SingleSelect({
onBlur: () => form.setFieldTouched(field.name),
}}
searchable
freeText
multiple={false}
freeText={false}
/>
{field.value?.length > 0 && (

View File

@@ -0,0 +1,78 @@
import React from "react";
import { FieldProps } from "formik";
import { Link } from "react-router-dom";
import queryString from "query-string";
import useRouter from "hooks/useRouter";
import {
makeStyles,
createStyles,
Grid,
Typography,
IconButton,
} from "@material-ui/core";
import LaunchIcon from "@material-ui/icons/Launch";
const useStyles = makeStyles(theme =>
createStyles({
labelContainer: {
borderRadius: theme.shape.borderRadius,
backgroundColor:
theme.palette.type === "light"
? "rgba(0, 0, 0, 0.09)"
: "rgba(255, 255, 255, 0.09)",
padding: theme.spacing(9 / 8, 1, 9 / 8, 1.5),
textAlign: "left",
minHeight: 56,
},
})
);
export default function SubTable({
form,
field,
label,
parentLabel,
}: FieldProps<any> & { parentLabel?: string; label: string }) {
const classes = useStyles();
const router = useRouter();
const parentLabels = queryString.parse(router.location.search).parentLabel;
let subTablePath = "";
if (parentLabels)
subTablePath =
encodeURIComponent(`${form.values.ref.path}/${field.name}`) +
`?parentLabel=${parentLabels},${
parentLabel ? form.values[parentLabel] : ""
}`;
else
subTablePath =
encodeURIComponent(`${form.values.ref.path}/${field.name}`) +
`?parentLabel=${
parentLabel ? encodeURIComponent(form.values[parentLabel]) : ""
}`;
return (
<Grid container wrap="nowrap">
<Grid container alignItems="center" className={classes.labelContainer}>
<Typography variant="body1">
{label}
{parentLabel && `: ${form.values[parentLabel]}`}
</Typography>
</Grid>
<IconButton
component={Link}
to={subTablePath}
style={{ width: 56 }}
disabled={!subTablePath}
>
<LaunchIcon />
</IconButton>
</Grid>
);
}

View File

@@ -72,6 +72,9 @@ const ConnectTable = lazy(() =>
"./Fields/ConnectTable" /* webpackChunkName: "SideDrawer-ConnectTable" */
)
);
const SubTable = lazy(() =>
import("./Fields/SubTable" /* webpackChunkName: "SideDrawer-SubTable" */)
);
const Action = lazy(() =>
import("./Fields/Action" /* webpackChunkName: "SideDrawer-Action" */)
);
@@ -299,7 +302,11 @@ export default function Form({ fields, values }: IFormProps) {
);
break;
// case FieldType.subTable:
case FieldType.subTable:
renderedField = (
<Field {...fieldProps} component={SubTable} />
);
break;
case FieldType.action:
renderedField = (

View File

@@ -87,6 +87,10 @@ export default function SideDrawer() {
field.config = column.config;
break;
case FieldType.subTable:
field.parentLabel = column.parentLabel;
break;
case FieldType.action:
field.callableName = column.callableName;
break;

View File

@@ -1,13 +1,7 @@
import React, { useState, useEffect } from "react";
import useAlgolia from "hooks/useAlgolia";
import { createStyles, makeStyles } from "@material-ui/core";
import MultiSelect from "components/MultiSelect";
const useStyles = makeStyles(theme =>
createStyles({
root: { minWidth: 200 },
})
);
import MultiSelect from "@antlerengineering/multiselect";
const AlgoliaSelect = (props: any) => {
const {
@@ -17,11 +11,7 @@ const AlgoliaSelect = (props: any) => {
labelReducer,
filters,
} = props;
const [searchState, searchDispatch] = useAlgolia(
algoliaIndex,
algoliaKey,
filters
);
const [searchState] = useAlgolia(algoliaIndex, algoliaKey, filters);
console.log(filters);
const [options, setOptions] = useState<any[]>([]);
@@ -40,6 +30,7 @@ const AlgoliaSelect = (props: any) => {
return (
<MultiSelect
multiple
options={options}
{...props}
searchable={options.length > 10} //shows type to filter after 10 options

View File

@@ -18,7 +18,7 @@ import {
import FilterIcon from "@material-ui/icons/FilterList";
import CloseIcon from "@material-ui/icons/Close";
import MultiSelect from "components/MultiSelect";
import MultiSelect from "@antlerengineering/multiselect";
import { FieldType } from "constants/fields";
import { FireTableFilter } from "hooks/useFiretable";
@@ -207,32 +207,36 @@ const Filters = ({ columns, setFilters }: any) => {
);
case FieldType.singleSelect:
const val = query?.value
? Array.isArray(query.value)
? query.value
: [query.value as string]
: [];
if (operator === "in")
return (
<MultiSelect
multiple
onChange={value => setQuery(query => ({ ...query, value }))}
options={selectedColumn.options}
label=""
value={Array.isArray(query?.value) ? query.value : []}
TextFieldProps={{ hiddenLabel: true }}
/>
);
return (
<MultiSelect
multiple={false}
onChange={value => {
if (operator === "==")
setQuery(query => ({ ...query, value: value[0] }));
else setQuery(query => ({ ...query, value }));
if (value !== null) setQuery(query => ({ ...query, value }));
}}
options={selectedColumn.options}
label=""
value={val}
multiple={operator === "in"}
value={typeof query?.value === "string" ? query.value : null}
TextFieldProps={{ hiddenLabel: true }}
/>
);
case FieldType.multiSelect:
return (
<MultiSelect
onChange={value => {
setQuery(query => ({ ...query, value }));
}}
multiple
onChange={value => setQuery(query => ({ ...query, value }))}
value={query.value as string[]}
options={selectedColumn.options}
label={""}

View File

@@ -18,10 +18,11 @@ import ExportCSV from "./ExportCSV";
import { FireTableFilter } from "hooks/useFiretable";
import { DRAWER_COLLAPSED_WIDTH } from "components/SideDrawer";
import { useFiretableContext } from "contexts/firetableContext";
import { FieldType } from "constants/fields";
export const TABLE_HEADER_HEIGHT = 56;
const useStyles = makeStyles(theme =>
const useStyles = makeStyles((theme) =>
createStyles({
root: {
width: `calc(100% - ${DRAWER_COLLAPSED_WIDTH}px)`,
@@ -85,7 +86,16 @@ export default function TableHeader({
>
<Grid item>
<Button
onClick={() => tableActions?.row.add()}
onClick={() => {
const initialVal = columns.reduce((acc, currCol) => {
if (currCol.type === FieldType.checkbox) {
return { ...acc, [currCol.key]: false };
} else {
return acc;
}
}, {});
tableActions?.row.add(initialVal);
}}
variant="contained"
color="primary"
startIcon={<AddIcon />}
@@ -118,7 +128,7 @@ export default function TableHeader({
variant="filled"
className={classes.formControl}
value={rowHeight ?? 43}
onChange={event => {
onChange={(event) => {
updateConfig("rowHeight", event.target.value);
}}
inputProps={{

View File

@@ -79,6 +79,7 @@ class TextEditor extends React.Component<
defaultValue={value}
type={inputType}
fullWidth
variant="standard"
inputProps={{ ref: this.inputRef }}
className={classes.root}
InputProps={{

View File

@@ -10,7 +10,7 @@ import {
import { green } from "@material-ui/core/colors";
import Confirmation from "components/Confirmation";
const useStyles = makeStyles(theme =>
const useStyles = makeStyles((theme) =>
createStyles({
root: { paddingLeft: theme.spacing(1.5) },
@@ -22,12 +22,12 @@ const useStyles = makeStyles(theme =>
overflowX: "hidden",
},
switchBase: {
"&$switchChecked": { color: green["A700"] },
"&$switchChecked + $switchTrack": { backgroundColor: green["A700"] },
},
switchChecked: {},
switchTrack: {},
// switchBase: {
// "&$switchChecked": { color: green["A700"] },
// "&$switchChecked + $switchTrack": { backgroundColor: green["A700"] },
// },
// switchChecked: {},
// switchTrack: {},
})
);
@@ -49,11 +49,12 @@ export default function Checkbox({
checked={!!value}
onChange={() => onSubmit(!value)}
disabled={!column.editable}
classes={{
switchBase: classes.switchBase,
checked: classes.switchChecked,
track: classes.switchTrack,
}}
classes={
{
// checked: classes.switchChecked,
// track: classes.switchTrack,
}
}
/>
);

View File

@@ -58,7 +58,7 @@ export default function Date({
const [handleDateChange] = useDebouncedCallback<DatePickerProps["onChange"]>(
date => {
if (!date || isNaN(date.valueOf())) return;
if (isNaN(date?.valueOf() ?? 0)) return;
onSubmit(date);
if (dataGridRef?.current?.selectCell)
@@ -75,8 +75,10 @@ export default function Date({
onClick={e => e.stopPropagation()}
format={fieldType === FieldType.date ? DATE_FORMAT : DATE_TIME_FORMAT}
fullWidth
clearable
keyboardIcon={<Icon />}
className={clsx("cell-collapse-padding", classes.root)}
inputVariant="standard"
InputProps={{
disableUnderline: true,
classes: { root: classes.inputBase, input: classes.input },

View File

@@ -4,43 +4,48 @@ import { CustomCellProps } from "./withCustomCell";
import { makeStyles, createStyles, Grid } from "@material-ui/core";
import MultiSelect_ from "components/MultiSelect";
import FormattedChip from "components/FormattedChip";
import MultiSelect_ from "@antlerengineering/multiselect";
import FormattedChip, { VARIANTS } from "components/FormattedChip";
import { FieldType } from "constants/fields";
import { useFiretableContext } from "contexts/firetableContext";
const useStyles = makeStyles(theme =>
createStyles({
root: {
minWidth: 0,
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
fullHeight: {
inputBase: {
height: "100%",
font: "inherit",
color: "inherit",
color: "inherit !important",
letterSpacing: "inherit",
},
select: {
padding: theme.spacing(0, 3, 0, 1.5),
height: "100%",
display: "flex",
alignItems: "center",
whiteSpace: "pre-line",
padding: theme.spacing(0, 4, 0, 1.5),
"&&": { paddingRight: theme.spacing(4) },
},
icon: { marginRight: theme.spacing(1) },
selectSingleLabel: {
maxHeight: "100%",
overflow: "hidden",
},
icon: { right: theme.spacing(1) },
chipList: {
overflowX: "hidden",
width: "100%",
},
chip: { cursor: "inherit" },
chipLabel: { whiteSpace: "nowrap" },
})
);
@@ -58,67 +63,70 @@ export default function MultiSelect({
// Support SingleSelect field
const isSingle = (column as any).type === FieldType.singleSelect;
// If SingleSelect, transform string to array of strings
const transformedValue = isSingle
? (([value] as unknown) as string[])
: value;
// And support transforming array of strings back to string
const handleChange = value => onSubmit(isSingle ? value.join(", ") : value);
// Render chips or basic string
const renderValue = isSingle
? () =>
typeof value === "string" && VARIANTS.includes(value.toLowerCase()) ? (
<FormattedChip label={value} className={classes.chip} />
) : (
<span className={classes.selectSingleLabel}>{value}</span>
)
: () => (
<Grid container spacing={1} wrap="nowrap" className={classes.chipList}>
{value?.map(
item =>
typeof item === "string" && (
<Grid item key={item}>
<FormattedChip
label={item}
className={classes.chip}
classes={{ label: classes.chipLabel }}
/>
</Grid>
)
)}
</Grid>
);
// Render chips
const renderValue = value => (
<Grid container spacing={1} wrap="nowrap" className={classes.chipList}>
{value?.map(
item =>
typeof item === "string" && (
<Grid item key={item}>
<FormattedChip label={item} className={classes.chip} />
</Grid>
)
)}
</Grid>
);
const onClick = e => e.stopPropagation();
const onClose = () => {
const handleOpen = () => {
if (dataGridRef?.current?.selectCell)
dataGridRef.current.selectCell({ rowIdx, idx: column.idx });
};
return (
<MultiSelect_
value={transformedValue}
onChange={handleChange}
label={column.name}
options={options}
TextFieldProps={{
disabled: column.editable === false,
fullWidth: true,
label: "",
hiddenLabel: true,
variant: "standard" as "filled",
InputProps: {
disableUnderline: true,
classes: { root: classes.fullHeight },
},
SelectProps: {
onClose,
classes: {
root: clsx(classes.fullHeight, classes.select),
icon: classes.icon,
},
renderValue,
MenuProps: {
anchorOrigin: { vertical: "bottom", horizontal: "left" },
transformOrigin: { vertical: "top", horizontal: "left" },
},
},
onClick,
}}
searchable
value={value === undefined ? (isSingle ? null : []) : value}
onChange={onSubmit}
freeText={false}
className={clsx(classes.fullHeight, classes.root)}
multiple={!isSingle}
multiple={!isSingle as any}
label={column.name}
labelPlural={column.name}
options={options}
disabled={column.editable === false}
onOpen={handleOpen}
TextFieldProps={
{
label: "",
hiddenLabel: true,
variant: "standard",
className: classes.root,
InputProps: {
disableUnderline: true,
classes: { root: classes.inputBase },
},
SelectProps: {
classes: {
root: clsx(classes.root, classes.select),
icon: classes.icon,
},
renderValue,
MenuProps: {
anchorOrigin: { vertical: "bottom", horizontal: "left" },
transformOrigin: { vertical: "top", horizontal: "left" },
},
},
} as const
}
/>
);
}

View File

@@ -1,10 +1,20 @@
import React from "react";
import { CustomCellProps } from "./withCustomCell";
import { Link } from "@material-ui/core";
export default function Url({ value }: CustomCellProps) {
if (!value) return null;
const href = value.includes("http") ? value : `https://${value}`;
return (
<a href={value} target="_blank" rel="noopener noreferrer">
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
underline="always"
style={{ fontWeight: "bold" }}
>
{value}
</a>
</Link>
);
}

View File

@@ -5,6 +5,8 @@ export enum routes {
table = "/table",
tableWithId = "/table/:id",
grid = "/grid",
gridWithId = "/grid/:id",
editor = "/editor",
}

View File

@@ -122,7 +122,7 @@ export const FiretableContextProvider: React.FC = ({ children }) => {
fieldName: string,
value: any
) => {
if (value === null || value === undefined) return;
if (value === undefined) return;
const ftUser = firetableUser(currentUser);
const _ft_updatedAt = new Date();

View File

@@ -0,0 +1,39 @@
import React from "react";
import queryString from "query-string";
import { Hidden } from "@material-ui/core";
import Navigation from "components/Navigation";
import Grid from "components/Grid";
import SideDrawer from "components/SideDrawer";
import { FireTableFilter } from "hooks/useFiretable";
import useRouter from "hooks/useRouter";
export default function GridView() {
const router = useRouter();
const tableCollection = decodeURIComponent(router.match.params.id);
let filters: FireTableFilter[] = [];
const parsed = queryString.parse(router.location.search);
if (typeof parsed.filters === "string") {
// decoded
//[{"key":"cohort","operator":"==","value":"AMS1"}]
filters = JSON.parse(parsed.filters);
//TODO: json schema validator
}
return (
<Navigation tableCollection={tableCollection}>
<Grid
key={tableCollection}
collection={tableCollection}
filters={filters}
/>
<Hidden smDown>
<SideDrawer />
</Hidden>
</Navigation>
);
}

View File

@@ -106,6 +106,20 @@
"@algolia/logger-common" "4.1.0"
"@algolia/requester-common" "4.1.0"
"@antlerengineering/components@^0.2.7":
version "0.2.7"
resolved "https://registry.yarnpkg.com/@antlerengineering/components/-/components-0.2.7.tgz#9d05d5ad841f85ab75d0c5b3bd8e37f1aa58a679"
integrity sha512-3lK/3VO+hEdNkOsPComvGVxr/30rTre0W5H4kWQcmdUi7nkB65WMRjnUcu1/IGBctTGlZiygv38W9DterND/7A==
dependencies:
dompurify "^2.0.10"
lodash "^4.17.15"
lodash-es "^4.17.15"
"@antlerengineering/multiselect@^0.3.9":
version "0.3.9"
resolved "https://registry.yarnpkg.com/@antlerengineering/multiselect/-/multiselect-0.3.9.tgz#a368c8b26227c37c80ac225d0007a999b0242997"
integrity sha512-lSWjuiESiM4GgjSVF0eqWvVrSxQcFMB9dlO5stM21NtAH4UNnoTtEolKdfGataA3CPCEM3lFxSagiC3is2bK+Q==
"@babel/code-frame@7.8.3", "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e"
@@ -1555,7 +1569,7 @@
"@babel/traverse" "^7.6.2"
jscodeshift-add-imports "^1.0.1"
"@material-ui/core@^4.7.1", "@material-ui/core@^4.9.4":
"@material-ui/core@^4.7.1":
version "4.9.7"
resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.9.7.tgz#0c1caf123278770f34c5d8e9ecd9e1314f87a621"
integrity sha512-RTRibZgq572GHEskMAG4sP+bt3P3XyIkv3pOTR8grZAW2rSUd6JoGZLRM4S2HkuO7wS7cAU5SpU2s1EsmTgWog==
@@ -1573,6 +1587,25 @@
react-is "^16.8.0"
react-transition-group "^4.3.0"
"@material-ui/core@^4.9.13":
version "4.9.13"
resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.9.13.tgz#024962bcdda05139e1bad17a1815bf4088702b15"
integrity sha512-GEXNwUr+laZ0N+F1efmHB64Fyg+uQIRXLqbSejg3ebSXgLYNpIjnMOPRfWdu4rICq0dAIgvvNXGkKDMcf3AMpA==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/react-transition-group" "^4.3.0"
"@material-ui/styles" "^4.9.13"
"@material-ui/system" "^4.9.13"
"@material-ui/types" "^5.0.1"
"@material-ui/utils" "^4.9.12"
"@types/react-transition-group" "^4.2.0"
clsx "^1.0.4"
hoist-non-react-statics "^3.3.2"
popper.js "^1.16.1-lts"
prop-types "^15.7.2"
react-is "^16.8.0"
react-transition-group "^4.3.0"
"@material-ui/icons@^4.5.1", "@material-ui/icons@^4.9.1":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-4.9.1.tgz#fdeadf8cb3d89208945b33dbc50c7c616d0bd665"
@@ -1580,10 +1613,10 @@
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/lab@^4.0.0-alpha.44":
version "4.0.0-alpha.46"
resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.46.tgz#453546d58d3a0064263c198dedb6a1c54d24d1a4"
integrity sha512-JGgZmj1UNP8bbYNAGvndipjXRK3x2+9mFBzbX7MyCj+WpfnJbeqTmJK2No9MXvPj/EZJ1piaKif46FdDc4U93A==
"@material-ui/lab@^4.0.0-alpha.52":
version "4.0.0-alpha.52"
resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.52.tgz#a868d8c772a90db091a6bfc89aed21a0e2c486bf"
integrity sha512-aDoRWA+q3/T2spvvQrxPz8qjtf9l8NhaG9ZISBy9zD3cJ05s3KfEP2nrsoBOBgonlSMFQQzUTnxAwThwsJfllw==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/utils" "^4.9.6"
@@ -1603,6 +1636,38 @@
react-transition-group "^4.0.0"
rifm "^0.7.0"
"@material-ui/react-transition-group@^4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@material-ui/react-transition-group/-/react-transition-group-4.3.0.tgz#92529142addb5cc179dbf42d246c7e3fe4d6104b"
integrity sha512-CwQ0aXrlUynUTY6sh3UvKuvye1o92en20VGAs6TORnSxUYeRmkX8YeTUN3lAkGiBX1z222FxLFO36WWh6q73rQ==
dependencies:
"@babel/runtime" "^7.5.5"
dom-helpers "^5.0.1"
loose-envify "^1.4.0"
prop-types "^15.6.2"
"@material-ui/styles@^4.9.13":
version "4.9.13"
resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.9.13.tgz#08b3976bdd21c38bc076693d95834f97539f3b15"
integrity sha512-lWlXJanBdHQ18jW/yphedRokHcvZD1GdGzUF/wQxKDsHwDDfO45ZkAxuSBI202dG+r1Ph483Z3pFykO2obeSRA==
dependencies:
"@babel/runtime" "^7.4.4"
"@emotion/hash" "^0.8.0"
"@material-ui/types" "^5.0.1"
"@material-ui/utils" "^4.9.6"
clsx "^1.0.4"
csstype "^2.5.2"
hoist-non-react-statics "^3.3.2"
jss "^10.0.3"
jss-plugin-camel-case "^10.0.3"
jss-plugin-default-unit "^10.0.3"
jss-plugin-global "^10.0.3"
jss-plugin-nested "^10.0.3"
jss-plugin-props-sort "^10.0.3"
jss-plugin-rule-value-function "^10.0.3"
jss-plugin-vendor-prefixer "^10.0.3"
prop-types "^15.7.2"
"@material-ui/styles@^4.9.6":
version "4.9.6"
resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.9.6.tgz#924a30bf7c9b91af9c8f19c12c8573b8a4ecd085"
@@ -1625,6 +1690,15 @@
jss-plugin-vendor-prefixer "^10.0.3"
prop-types "^15.7.2"
"@material-ui/system@^4.9.13":
version "4.9.13"
resolved "https://registry.yarnpkg.com/@material-ui/system/-/system-4.9.13.tgz#adefb3b6a5ddf0b00fe4e82ac63bb48276e9749d"
integrity sha512-6AlpvdW6KJJ5bF1Xo2OD13sCN8k+nlL36412/bWnWZOKIfIMo/Lb8c8d1DOIaT/RKWxTEUaWnKZjabVnA3eZjA==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/utils" "^4.9.6"
prop-types "^15.7.2"
"@material-ui/system@^4.9.6":
version "4.9.6"
resolved "https://registry.yarnpkg.com/@material-ui/system/-/system-4.9.6.tgz#fd060540224da4d1740da8ca6e7af288e217717e"
@@ -1639,6 +1713,20 @@
resolved "https://registry.yarnpkg.com/@material-ui/types/-/types-5.0.0.tgz#26d6259dc6b39f4c2e1e9aceff7a11e031941741"
integrity sha512-UeH2BuKkwDndtMSS0qgx1kCzSMw+ydtj0xx/XbFtxNSTlXydKwzs5gVW5ZKsFlAkwoOOQ9TIsyoCC8hq18tOwg==
"@material-ui/types@^5.0.1":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@material-ui/types/-/types-5.0.1.tgz#c4954063cdc196eb327ee62c041368b1aebb6d61"
integrity sha512-wURPSY7/3+MAtng3i26g+WKwwNE3HEeqa/trDBR5+zWKmcjO+u9t7Npu/J1r+3dmIa/OeziN9D/18IrBKvKffw==
"@material-ui/utils@^4.9.12":
version "4.9.12"
resolved "https://registry.yarnpkg.com/@material-ui/utils/-/utils-4.9.12.tgz#0d639f1c1ed83fffb2ae10c21d15a938795d9e65"
integrity sha512-/0rgZPEOcZq5CFA4+4n6Q6zk7fi8skHhH2Bcra8R3epoJEYy5PL55LuMazPtPH1oKeRausDV/Omz4BbgFsn1HQ==
dependencies:
"@babel/runtime" "^7.4.4"
prop-types "^15.7.2"
react-is "^16.8.0"
"@material-ui/utils@^4.9.6":
version "4.9.6"
resolved "https://registry.yarnpkg.com/@material-ui/utils/-/utils-4.9.6.tgz#5f1f9f6e4df9c8b6a263293b68c94834248ff157"
@@ -4997,6 +5085,11 @@ domhandler@^3.0.0:
dependencies:
domelementtype "^2.0.1"
dompurify@^2.0.10:
version "2.0.11"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.0.11.tgz#cd47935774230c5e478b183a572e726300b3891d"
integrity sha512-qVoGPjIW9IqxRij7klDQQ2j6nSe4UNWANBhZNLnsS7ScTtLb+3YdxkRY8brNTpkUiTtcXsCJO+jS0UCDfenLuA==
dompurify@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.0.8.tgz#6ef89d2d227d041af139c7b01d9f67ed59c2eb3c"
@@ -8899,7 +8992,7 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
lodash-es@^4.17.14, lodash-es@^4.2.1:
lodash-es@^4.17.14, lodash-es@^4.17.15, lodash-es@^4.2.1:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78"
integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==
@@ -10643,7 +10736,7 @@ pnp-webpack-plugin@1.6.0:
dependencies:
ts-pnp "^1.1.2"
popper.js@^1.14.1:
popper.js@^1.14.1, popper.js@^1.16.1-lts:
version "1.16.1"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b"
integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==
@@ -14007,6 +14100,11 @@ url@^0.11.0:
punycode "1.3.2"
querystring "0.2.0"
use-algolia@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/use-algolia/-/use-algolia-1.3.0.tgz#a53623b9c248bad14ca1cba551658c625613cb72"
integrity sha512-t6rY8apz0CmeNUbVchsi0+7UIeutJ8FPNfGTbx/f2vCw1LFYAo9ZUrG81vIsKMAhSLP2FKEXeztDWYr976MEpw==
use-debounce@^3.3.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-3.4.0.tgz#e61653fd4daad9beaa6e4695bc1d3fbd35f7e5b3"