Merge pull request #147 from AntlerVC/develop

persistent personal filters, update cloudfunctions
This commit is contained in:
AntlerEngineering
2020-07-17 09:28:27 +10:00
committed by GitHub
25 changed files with 368 additions and 970 deletions

View File

@@ -2,6 +2,8 @@ import * as algoliasearch from "algoliasearch";
import * as functions from "firebase-functions";
import * as _ from "lodash";
import { env } from "../config";
import config from "../functionConfig"; // generated using generateConfig.ts
const functionConfig: any = config;
const APP_ID = env.algolia.app;
const ADMIN_KEY = env.algolia.key;
@@ -181,4 +183,7 @@ const algoliaFnsGenerator = collection => ({
// console.log(JSON.stringify(afterData));
// }),
// });
export default algoliaFnsGenerator;
//export default algoliaFnsGenerator;
export const FT_algolia = {
[functionConfig.name]: { ...algoliaFnsGenerator(functionConfig) },
};

View File

@@ -4,6 +4,8 @@ import { db } from "../config";
import * as _ from "lodash";
import { replacer } from "../utils/email";
import config from "../functionConfig"; // generated using generateConfig.ts
const functionConfig: any = config;
// returns object of fieldsToSync
const docReducer = (docData: FirebaseFirestore.DocumentData) => (
acc: any,
@@ -127,4 +129,11 @@ const collectionSyncFnsGenerator = collection =>
: null,
}).reduce((a, [k, v]) => (v === null ? a : { ...a, [k]: v }), {});
export default collectionSyncFnsGenerator;
//export default collectionSyncFnsGenerator;
export const FT_sync = {
[`${`${`${functionConfig.source}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}2${`${`${functionConfig.target}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}`]: collectionSyncFnsGenerator(functionConfig),
};

View File

@@ -0,0 +1,58 @@
import * as functions from "firebase-functions";
import { db } from "../config";
import config, { collectionPath } from "../functionConfig";
// generated using generateConfig.ts
const functionConfig: any = config;
const shouldEvaluateReducer = (listeners, before, after) =>
listeners.reduce((acc: Boolean, currField: string) => {
if (acc) return true;
else return before[currField] !== after[currField];
}, false);
export const derivativeOnChange = async (
Change: functions.Change<FirebaseFirestore.DocumentSnapshot>
) => {
const beforeData = Change.before.data();
const afterData = Change.after.data();
if (!beforeData || !afterData) return false;
const update = await functionConfig.reduce(
async (accUpdates: any, currDerivative) => {
const shouldEval = shouldEvaluateReducer(
currDerivative.listenerFields,
beforeData,
afterData
);
if (shouldEval) {
const newValue = await currDerivative.eval(db)(afterData);
if (newValue !== undefined) {
return {
...(await accUpdates),
[currDerivative.fieldName]: newValue,
};
}
}
return await accUpdates;
},
{}
);
if (Object.keys(update).length !== 0) {
return Change.after.ref.update(update);
}
return false;
};
/**
*
* @param collection configuration object
*/
export const FT_derivatives = {
[collectionPath]: {
// onCreate: functions.firestore
// .document(`employees/{docId}`)
// .onCreate(addSynonymOnCreate(collection.groups)),
onUpdate: functions.firestore
.document(`${collectionPath}/{docId}`)
.onUpdate(derivativeOnChange),
},
};

View File

@@ -0,0 +1,2 @@
export const collectionPath = "teams";
export default [];

View File

@@ -1,7 +1,68 @@
const fs = require("fs");
// Initialize Firebase Admin
import * as admin from "firebase-admin";
// Initialize Firebase Admin
//const serverTimestamp = admin.firestore.FieldValue.serverTimestamp;
const main = configString => {
fs.writeFileSync("./src/functionConfig.ts", `export default ${configString}`);
};
const serviceAccount = requireIfExists("../firebase-credentials.json");
main(process.argv[2]);
function requireIfExists(module) {
try {
return require(module);
} catch (error) {
console.log("serviceAccount json not found");
return false;
}
}
if (serviceAccount) {
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 main = async (functionType: string, configString: string) => {
let configData;
switch (functionType) {
case "FT_derivatives":
const schemaDoc = await db
.doc(`_FIRETABLE_/settings/schema/${configString}`)
.get();
const schemaData = schemaDoc.data();
if (!schemaData) return;
const derivativeColumns = Object.values(schemaData.columns).filter(
(col: any) => col.type === "DERIVATIVE"
);
console.log(derivativeColumns);
const config = derivativeColumns.reduce((acc, currColumn: any) => {
return `${acc}{
fieldName:'${currColumn.key}',eval:(db)=> async (row) =>{${
currColumn.config.script
}},listenerFields:[${currColumn.config.listenerFields
.map(f => `"${f}"`)
.join(",")}]},`;
}, ``);
configData = `export default [${config}]\nexport const collectionPath ="${configString}"`;
break;
case "FT_subTableStats":
configData = `export const collectionPath ="${configString}"\nexport default []`;
break;
default:
configData = `export default ${configString}`;
break;
}
fs.writeFileSync("./src/functionConfig.ts", configData);
return;
};
main(process.argv[2], process.argv[3])
.catch(err => console.log(err))
.then(() => console.log("this will succeed"))
.catch(() => "obligatory catch");
} else {
console.log("did not run generator");
}

View File

@@ -2,6 +2,9 @@ import * as functions from "firebase-functions";
import * as _ from "lodash";
import { db } from "../config";
import config from "../functionConfig"; // generated using generateConfig.ts
const functionConfig: any = config;
const historySnapshot = (trackedFields: string[]) => async (
change: functions.Change<FirebaseFirestore.DocumentSnapshot>
) => {
@@ -28,4 +31,10 @@ const historySnapshotFnsGenerator = collection =>
.document(`${collection.name}/{docId}`)
.onUpdate(historySnapshot(collection.trackedFields));
export default historySnapshotFnsGenerator;
//export default historySnapshotFnsGenerator;
export const FT_history = {
[functionConfig.name
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")]: historySnapshotFnsGenerator(functionConfig),
};

View File

@@ -5,49 +5,9 @@ export const callable = callableFns;
// all the cloud functions bellow are deployed using the triggerCloudBuild callable function
// these functions are designed to be built and deployed based on the configuration passed through the callable
import config from "./functionConfig"; // generated using generateConfig.ts
import algoliaFnsGenerator from "./algolia"; // algolia sync functions template
import collectionSyncFnsGenerator from "./collectionSync";
import snapshotSyncFnsGenerator from "./snapshotSync";
import collectionSnapshotFnsGenerator from "./history";
import permissionControlFnsGenerator from "./permissions";
const functionConfig: any = config;
export const FT_algolia = {
[functionConfig.name]: { ...algoliaFnsGenerator(functionConfig) },
};
// {
// [functionConfig.name]: { ...algoliaFnsGenerator(functionConfig) },
// };
export const FT_sync = {
[`${`${`${functionConfig.source}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}2${`${`${functionConfig.target}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}`]: collectionSyncFnsGenerator(functionConfig),
};
export const FT_history = {
[functionConfig.name
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")]: collectionSnapshotFnsGenerator(functionConfig),
};
export const FT_permissions = {
[functionConfig.name]: permissionControlFnsGenerator(functionConfig),
};
export const FT_snapshotSync = functionConfig.fnName
? { [functionConfig.fnName]: snapshotSyncFnsGenerator(functionConfig) }
: {
[`${`${`${functionConfig.source}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}2${`${`${functionConfig.target}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}`]: snapshotSyncFnsGenerator(
functionConfig
),
};
export { FT_derivatives } from "./derivatives";
export { FT_algolia } from "./algolia";
export { FT_sync } from "./collectionSync";
export { FT_snapshotSync } from "./snapshotSync";
//export { FT_history } from "./history";
export { FT_subTableStats } from "./subTableStats";

View File

@@ -1,96 +0,0 @@
import { firestore, Change, auth as FCAuth } from "firebase-functions";
import { auth, db } from "../config";
const email2uid = async (email: string) => {
const user = await auth.getUserByEmail(email);
console.log(
`${email} previous customClaims ${JSON.stringify(user.customClaims)}`
);
return user ? user.uid : undefined;
};
const setClaims = async (
uid: string,
doc: any,
customClaimsFields: string[]
) => {
const customClaimsTokens = customClaimsFields.reduce(
(token: any, currentField: string) => {
if (doc[currentField])
return { ...token, [currentField]: doc[currentField] };
else return token;
},
{}
);
await auth
.setCustomUserClaims(uid, customClaimsTokens)
.catch(err => console.log(err))
.then(() => {
console.info(
`${uid} has new claims ${JSON.stringify(customClaimsTokens)}`
);
})
.catch(() => "obligatory catch");
return true;
};
export const onSignup = (
collectionName: string,
customTokenFields: string[]
) => async (user: FCAuth.UserRecord) => {
if (user.emailVerified) {
const email = user.email;
const invites = await db
.collection(collectionName)
.where("email", "==", email)
.get();
if (invites.docs.length === 0) {
console.log("no invites found");
return false;
}
const inviteData = invites.docs[0].data();
await setClaims(user.uid, inviteData, customTokenFields);
return true;
}
console.error("email not verified on signup");
return false;
};
const permissions = (customClaimsFields: string[]) => async (
snapshot: FirebaseFirestore.DocumentSnapshot
) => {
const docData = snapshot.data();
if (!docData) {
console.log("no after Data");
return false;
}
const { email } = docData;
if (!email) {
console.warn(`row has no email`);
return false;
}
const uid = await email2uid(email);
if (!uid) {
console.warn(`user ${uid} does not exist`);
return false;
}
await setClaims(uid, docData, customClaimsFields);
return true;
};
const permissionsFnsGenerator = collection => ({
onCreate: firestore
.document(`${collection.name}/{docId}`)
.onCreate(permissions(collection.customTokenFields)),
onUpdate: firestore
.document(`${collection.name}/{docId}`)
.onUpdate((change: Change<FirebaseFirestore.DocumentSnapshot>) =>
permissions(collection.customTokenFields)(change.after)
),
onSignup: FCAuth.user().onCreate(
onSignup(collection.name, collection.customTokenFields)
),
});
export default permissionsFnsGenerator;

View File

@@ -5,6 +5,9 @@ import { db } from "../config";
import * as _ from "lodash";
import { replacer } from "../utils/email";
import Config from "../functionConfig"; // generated using generateConfig.ts
const functionConfig: any = Config;
enum TargetTypes {
subCollection = "subCollection",
document = "document",
@@ -162,4 +165,16 @@ const snapshotSyncFnsGenerator = config =>
: null,
}).reduce((a, [k, v]) => (v === null ? a : { ...a, [k]: v }), {});
export default snapshotSyncFnsGenerator;
//export default snapshotSyncFnsGenerator;
export const FT_snapshotSync = functionConfig.fnName
? { [functionConfig.fnName]: snapshotSyncFnsGenerator(functionConfig) }
: {
[`${`${`${functionConfig.source}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}2${`${`${functionConfig.target}`
.replace(/\//g, "_")
.replace(/_{.*?}_/g, "_")}`}`]: snapshotSyncFnsGenerator(
functionConfig
),
};

View File

@@ -0,0 +1,37 @@
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import { collectionPath } from "./functionConfig"; // generated using generateConfig.ts
const increment = admin.firestore.FieldValue.increment(1);
const decrement = admin.firestore.FieldValue.increment(-1);
const docCreated = (
snapshot: functions.firestore.DocumentSnapshot,
context: functions.EventContext
) => {
const { subCollectionId } = context.params;
return snapshot.ref.parent.parent?.update({
[`${subCollectionId}.count`]: increment,
});
};
const docDelete = (
snapshot: functions.firestore.DocumentSnapshot,
context: functions.EventContext
) => {
const { subCollectionId } = context.params;
return snapshot.ref.parent.parent?.update({
[subCollectionId]: decrement,
});
};
const subTableFnsGenerator = {
onCreate: functions.firestore
.document(`${collectionPath}/{parentId}/{subCollectionId}/{docId}`)
.onCreate(docCreated),
onDelete: functions.firestore
.document(`${collectionPath}/{parentId}/{subCollectionId}/{docId}`)
.onDelete(docDelete),
};
export const FT_subTableStats = {
[collectionPath]: { ...subTableFnsGenerator },
};

View File

@@ -21,6 +21,7 @@ steps:
entrypoint: yarn
args:
- "generateConfig"
- "${_FUNCTIONS_GROUP}"
- "${_FUNCTION_CONFIG}"
dir: "cloud_functions/functions"
- name: node:10.15.1

View File

@@ -72,28 +72,10 @@ export default function Navigation({
const section = _find(tables, ["collection", tableCollection?.split("/")[0]])
?.section;
// Get the table path, including filtering for regions & user permissions
// Get the table path, including filtering for user permissions
const getTablePath = (table: Table): LinkProps["to"] => {
if (!table || !userClaims) return "";
if (
userClaims.regions &&
table.regional &&
!userClaims.regions?.includes("GL")
)
return {
pathname: table.collection,
search: `?filters=${encodeURIComponent(
JSON.stringify([
{
key: "region",
operator: "==",
value: userClaims.regions[0],
},
])
)}`,
};
return table.collection;
};

View File

@@ -1,193 +0,0 @@
import React, { useContext, useEffect, useState } from "react";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Chip from "@material-ui/core/Chip";
import Grid from "@material-ui/core/Grid";
import TextField from "@material-ui/core/TextField";
import Divider from "@material-ui/core/Divider";
import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";
import useTableConfig from "../../../hooks/useFiretable/useTableConfig";
import FormControl from "@material-ui/core/FormControl";
import InputLabel from "@material-ui/core/InputLabel";
import Input from "@material-ui/core/Input";
const useStyles = makeStyles(Theme =>
createStyles({
root: {
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
maxWidth: 300,
padding: Theme.spacing(1),
},
chip: {
margin: Theme.spacing(0.5),
},
formControl: {
margin: Theme.spacing(1),
minWidth: 120,
maxWidth: 300,
},
chips: {
display: "flex",
flexWrap: "wrap",
},
})
);
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
PaperProps: {
style: {
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
width: 250,
},
},
};
export default function DocInput(props: any) {
const { collectionPath, setValue } = props;
const [tableConfig, tableConfigActions] = useTableConfig(
collectionPath ? collectionPath : "initial"
);
const [columns, setColumns] = useState<{ key: string; name: string }[]>([]);
const [primaryKeys, setPrimaryKeys] = useState<string[]>([]);
const [secondaryKeys, setSecondaryKeys] = useState<string[]>([]);
useEffect(() => {
setColumns(tableConfig.columns);
}, [tableConfig.columns]);
const classes = useStyles();
const onChange = (e: any, v: any) => {
setValue("collectionPath", v.props.value);
setPrimaryKeys([]);
setSecondaryKeys([]);
setColumns([]);
tableConfigActions.setTable(v.props.value);
};
useEffect(() => {
setValue("config", {
primaryKeys,
secondaryKeys,
});
}, [primaryKeys, secondaryKeys]);
// WAS USING TABLES CONTEXT RIP
// if (tables.value)
// return (
// <>
// <FormControl className={classes.formControl}>
// <InputLabel htmlFor="select-secondary-multiple-chip">
// Table
// </InputLabel>
// <Select
// value={collectionPath ? collectionPath : null}
// onChange={onChange}
// id={`select-key`}
// inputProps={{
// name: "Table",
// id: "table",
// }}
// >
// {tables.value.map(table => {
// return (
// <MenuItem
// id={`select-collection-${table.collection}`}
// value={table.collection}
// >
// <>{table.collection}</>
// </MenuItem>
// );
// })}
// </Select>
// </FormControl>
// {collectionPath ? (
// <>
// <FormControl className={classes.formControl}>
// <InputLabel htmlFor="select-primary-multiple-chip">
// Primary Text
// </InputLabel>
// <Select
// multiple
// value={primaryKeys}
// onChange={(event: React.ChangeEvent<{ value: unknown }>) => {
// setPrimaryKeys(event.target.value as string[]);
// }}
// input={<Input id="select-primary-multiple-chip" />}
// renderValue={selected => (
// <div className={classes.chips}>
// {(selected as string[]).map(value => (
// <Chip
// key={value}
// label={value}
// className={classes.chip}
// />
// ))}
// </div>
// )}
// MenuProps={MenuProps}
// >
// {columns &&
// columns.length !== 0 &&
// columns.map(column => (
// <MenuItem
// id={`select-primary-column-${column.key}`}
// key={column.key}
// value={column.key}
// >
// {column.name}
// </MenuItem>
// ))}
// </Select>
// </FormControl>
// <FormControl className={classes.formControl}>
// <InputLabel htmlFor="select-secondary-multiple-chip">
// Secondary Text
// </InputLabel>
// <Select
// multiple
// value={secondaryKeys}
// onChange={(event: React.ChangeEvent<{ value: unknown }>) => {
// setSecondaryKeys(event.target.value as string[]);
// }}
// input={<Input id="select-secondary-multiple-chip" />}
// renderValue={selected => (
// <div className={classes.chips}>
// {(selected as string[]).map(value => (
// <Chip
// key={value}
// label={value}
// className={classes.chip}
// />
// ))}
// </div>
// )}
// MenuProps={MenuProps}
// >
// {columns &&
// columns.length !== 0 &&
// columns.map(column => (
// <MenuItem
// id={`select-secondary-column-${column.key}`}
// key={column.key}
// value={column.key}
// >
// {column.name}
// </MenuItem>
// ))}
// </Select>
// </FormControl>
// </>
// ) : (
// "please select a table"
// )}
// </>
// );
// else
return <div />;
}

View File

@@ -1,39 +0,0 @@
import React from "react";
import { TextField, MenuItem, ListItemIcon } from "@material-ui/core";
import { FIELDS, FieldType } from "constants/fields";
/**
* Returns dropdown component of all available types
*/
const FieldsDropdown = (value: FieldType | null, onChange: any) => {
return (
<TextField
select
value={value ? value : ""}
onChange={onChange}
inputProps={{ name: "type", id: "type" }}
label="Field type"
>
{FIELDS.map(
(field: { icon: JSX.Element; name: string; type: FieldType }) => {
return (
<MenuItem
key={`select-field-${field.name}`}
id={`select-field-${field.type}`}
value={field.type}
>
<ListItemIcon style={{ verticalAlign: "text-bottom" }}>
{field.icon}
</ListItemIcon>
{field.name}
</MenuItem>
);
}
)}
</TextField>
);
};
export default FieldsDropdown;

View File

@@ -1,100 +0,0 @@
import React, { useEffect, useState } from "react";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Chip from "@material-ui/core/Chip";
import TextField from "@material-ui/core/TextField";
import Grid from "@material-ui/core/Grid";
import Divider from "@material-ui/core/Divider";
import _includes from "lodash/includes";
import _camelCase from "lodash/camelCase";
import AddIcon from "@material-ui/icons/AddCircle";
import IconButton from "@material-ui/core/IconButton";
import InputAdornment from "@material-ui/core/InputAdornment";
const useStyles = makeStyles(Theme =>
createStyles({
root: {},
field: {
width: "100%",
},
chipsContainer: {
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
maxWidth: 300,
padding: Theme.spacing(1),
},
chip: {
margin: Theme.spacing(0.5),
},
})
);
export default function SelectOptionsInput(props: any) {
const { options, setValue } = props;
const classes = useStyles();
const [newOption, setNewOption] = useState("");
const handleAdd = () => {
// setOptions([...options, newOption]);
if (newOption !== "") {
setValue("options", [...options, newOption]);
setNewOption("");
}
};
const handleDelete = (optionToDelete: string) => () => {
const newOptions = options.filter(
(option: string) => option !== optionToDelete
);
setValue("options", newOptions);
};
useEffect(() => {
setValue({ data: { options } });
}, [options]);
return (
<Grid container direction="column" className={classes.root}>
<Grid item>
<TextField
value={newOption}
className={classes.field}
label="New Option"
onChange={e => {
setNewOption(e.target.value);
}}
onKeyPress={(e: any) => {
if (e.key === "Enter") {
handleAdd();
}
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
edge="end"
aria-label="toggle password visibility"
onClick={(e: any) => {
handleAdd();
}}
>
{<AddIcon />}
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
<Grid item className={classes.chipsContainer}>
{options.map((option: string) => {
return (
<Chip
key={option}
label={option}
onDelete={handleDelete(option)}
className={classes.chip}
/>
);
})}
</Grid>
</Grid>
);
}

View File

@@ -1,378 +0,0 @@
import React, { useEffect, useState } from "react";
import _findIndex from "lodash/findIndex";
import Button from "@material-ui/core/Button";
import FormControl from "@material-ui/core/FormControl";
import TextField from "@material-ui/core/TextField";
import Grid from "@material-ui/core/Grid";
import Popover from "@material-ui/core/Popover";
import { createStyles, makeStyles } from "@material-ui/core/styles";
import FieldsDropdown from "./FieldsDropdown";
import { FieldType } from "constants/fields";
import ToggleButton from "@material-ui/lab/ToggleButton";
import ToggleButtonGroup from "@material-ui/lab/ToggleButtonGroup";
import LockIcon from "@material-ui/icons/Lock";
import LockOpenIcon from "@material-ui/icons/LockOpen";
import VisibilityIcon from "@material-ui/icons/Visibility";
import VisibilityOffIcon from "@material-ui/icons/VisibilityOff";
import FrozenIcon from "@material-ui/icons/AcUnit";
import ResizeIcon from "@material-ui/icons/PhotoSizeSelectSmall";
import DeleteIcon from "@material-ui/icons/Delete";
import SelectOptionsInput from "./SelectOptionsInput";
import DocInput from "./DocInput";
import { Tooltip } from "@material-ui/core";
import Confirmation from "../../Confirmation";
import { useFiretableContext } from "contexts/firetableContext";
const useStyles = makeStyles(theme =>
createStyles({
container: {
padding: 15,
},
typography: {
padding: 1,
},
button: {
// margin: theme.spacing(1)
},
root: {
padding: 10,
display: "flex",
flexWrap: "wrap",
},
formControl: {
margin: theme.spacing(1),
minWidth: 120,
},
selectEmpty: {
marginTop: theme.spacing(2),
},
toggleGrouped: {
margin: theme.spacing(0.5),
border: "none",
padding: theme.spacing(0, 1),
"&:not(:first-child)": {
borderRadius: theme.shape.borderRadius,
},
"&:first-child": {
borderRadius: theme.shape.borderRadius,
},
},
})
);
// TODO: REMOVE THIS OLD COMPONENT
export default function ColumnEditor() {
const {
tableState,
tableActions,
selectedColumnHeader,
setSelectedColumnHeader,
} = useFiretableContext() as any;
const actions = tableActions!.column;
const { column, anchorEl } = selectedColumnHeader ?? {};
const handleClose = () => {
if (setSelectedColumnHeader) setSelectedColumnHeader(null);
};
const [values, setValues] = useState({
type: null,
name: "",
options: [],
collectionPath: "",
config: {},
parentLabel: "",
callableName: "",
});
const [flags, setFlags] = useState(() => [""]);
const classes = useStyles();
function handleChange(
event: React.ChangeEvent<{ name?: string; value: unknown }>
) {
event.stopPropagation();
event.preventDefault();
setValues(oldValues => ({
...oldValues,
[event.target.name as string]: event.target.value,
}));
}
const setValue = (key: string, value: any) => {
setValues(oldValues => ({
...oldValues,
[key]: value,
}));
};
useEffect(() => {
if (column && !column.isNew) {
setValues(oldValues => ({
...oldValues,
name: column.name,
type: column.type,
key: column.key,
isNew: column.isNew,
}));
if (column.options) {
setValue("options", column.options);
} else {
setValue("options", []);
}
if (column.collectionPath) {
setValue("collectionPath", column.collectionPath);
}
const flags = ["resizable", "editable", "fixed", "hidden"].filter(
flag =>
column[flag] === true ||
(flag === "resizable" && column[flag] !== false)
);
setFlags(flags);
}
}, [column]);
const clearValues = () => {
setValues({
type: null,
name: "",
options: [],
collectionPath: "",
config: {},
parentLabel: "",
callableName: "",
});
};
const onClose = (event: any) => {
handleClose();
clearValues();
};
const handleToggle = (
event: React.MouseEvent<HTMLElement>,
newFlags: string[]
) => {
setFlags(newFlags);
};
const createNewColumn = () => {
const { name, type, options, collectionPath, config, parentLabel } = values;
actions.add(name, type, { options, collectionPath, config, parentLabel });
handleClose();
clearValues();
};
// WARNING: column.idx does NOT map to how we store column config in Firestore
// when a column is fixed AND it is not the first column. We should NOT be
// using indexes to manipulate column config anyway!
const configColumnIndex = _findIndex(tableState?.columns, [
"key",
column?.key,
]);
const deleteColumn = () => {
actions.remove(column?.key);
handleClose();
clearValues();
};
const updateColumn = () => {
let updatables: { field: string; value: any }[] = [
{ field: "name", value: values.name },
{ field: "type", value: values.type },
// TEMP: disable resizing fixed columns due to the problem described
// on line 169
{
field: "resizable",
value: !flags.includes("fixed") && flags.includes("resizable"),
},
{ field: "editable", value: flags.includes("editable") },
{ field: "hidden", value: flags.includes("hidden") },
{ field: "fixed", value: flags.includes("fixed") },
];
if (
values.type === FieldType.multiSelect ||
values.type === FieldType.singleSelect
) {
updatables.push({
field: "options",
value: values.options,
});
}
if (values.type === FieldType.connectTable) {
updatables.push({
field: "collectionPath",
value: values.collectionPath,
});
updatables.push({
field: "config",
value: values.config,
});
}
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(configColumnIndex, updatables);
handleClose();
clearValues();
};
const disableAdd = () => {
const { type, name, options, collectionPath, config } = values;
if (!type || name === "") return true;
//TODO: Add more validation
return false;
};
if (column)
return (
<Popover
className={classes.root}
id={`id-${column.name}`}
open={!!anchorEl}
anchorEl={anchorEl}
anchorOrigin={{ horizontal: "center", vertical: "bottom" }}
transformOrigin={{ horizontal: "center", vertical: "top" }}
onClose={onClose}
>
<Grid container className={classes.container} direction="column">
<ToggleButtonGroup
size="small"
value={flags}
className={classes.toggleGrouped}
onChange={handleToggle}
arial-label="column settings"
>
<ToggleButton value="editable" aria-label="editable">
<Tooltip title={flags.includes("editable") ? "Lock" : "Unlock"}>
{flags.includes("editable") ? <LockOpenIcon /> : <LockIcon />}
</Tooltip>
</ToggleButton>
{/* <Tooltip title="Hide Column">
<ToggleButton value="visible" aria-label="visible">
{flags.includes("visible") ? (
<VisibilityIcon />
) : (
<VisibilityOffIcon />
)}
</ToggleButton>
</Tooltip> */}
<ToggleButton value="fixed" aria-label="fixed">
<Tooltip
title={
flags.includes("fixed") ? "Unfreeze Column" : "Freeze Column"
}
>
<FrozenIcon />
</Tooltip>
</ToggleButton>
<ToggleButton
value="resizable"
aria-label="resizable"
disabled={flags.includes("fixed")}
>
<Tooltip
title={
flags.includes("resizable")
? "Disable column resize"
: "Enable column resize"
}
>
<ResizeIcon />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
<TextField
label="Column name"
name="name"
defaultValue={values.name}
onChange={e => {
setValue("name", e.target.value);
}}
/>
<FormControl className={classes.formControl}>
{FieldsDropdown(values.type, handleChange)}
{(values.type === FieldType.singleSelect ||
values.type === FieldType.multiSelect) && (
<SelectOptionsInput
setValue={setValue}
options={values.options}
/>
)}
{values.type === FieldType.connectTable && (
<DocInput
setValue={setValue}
collectionPath={values.collectionPath}
/>
)}
{values.type === FieldType.subTable && (
<TextField
label={"Parent Label"}
onChange={e => {
setValue("parentLabel", e.target.value);
}}
/>
)}
{values.type === FieldType.action && (
<TextField
label={"Callable Name"}
onChange={e => {
setValue("callableName", e.target.value);
}}
/>
)}
<Grid container>
<Grid item xs={6}>
{column.isNew ? (
<Button
onClick={createNewColumn}
disabled={disableAdd()}
fullWidth
>
Add
</Button>
) : (
<Button
disabled={disableAdd()}
onClick={updateColumn}
fullWidth
>
update
</Button>
)}
</Grid>
{!column.isNew && (
<Grid item xs={6}>
<Confirmation
message={{
customBody:
"Are you sure you want to delete this nice column?",
}}
>
<Button onClick={deleteColumn} fullWidth>
Delete
</Button>
</Confirmation>
</Grid>
)}
</Grid>
</FormControl>
</Grid>
</Popover>
);
return null;
}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react";
import _find from "lodash/find";
import _sortBy from "lodash/sortBy";
import {
makeStyles,
@@ -8,12 +9,11 @@ import {
Button,
Typography,
IconButton,
FormControl,
Grid,
Select,
MenuItem,
TextField,
Switch,
Chip,
} from "@material-ui/core";
import FilterIcon from "@material-ui/icons/FilterList";
import CloseIcon from "@material-ui/icons/Close";
@@ -25,6 +25,8 @@ import { FireTableFilter } from "hooks/useFiretable";
import { useFiretableContext } from "contexts/firetableContext";
import DocSelector from "./DocSelector";
import { useAppContext } from "contexts/appContext";
import { DocActions } from "hooks/useDoc";
const OPERATORS = [
{
value: "==",
@@ -94,10 +96,30 @@ const useStyles = makeStyles(theme =>
})
);
const Filters = ({ columns, setFilters }: any) => {
const { userClaims } = useFiretableContext();
const filterColumns = columns
.filter(c => c.type !== FieldType.file && c.type !== FieldType.image)
const UNFILTERABLES = [
FieldType.image,
FieldType.file,
FieldType.action,
FieldType.subTable,
FieldType.last,
FieldType.longText,
];
const Filters = () => {
const { userClaims, tableState, tableActions } = useFiretableContext();
const { userDoc } = useAppContext();
useEffect(() => {
if (userDoc.state.doc && tableState?.tablePath) {
if (userDoc.state.doc.tables?.[tableState?.tablePath]?.filters) {
console.log(userDoc.state.doc.tables[tableState?.tablePath].filters);
tableActions?.table.filter(
userDoc.state.doc.tables[tableState?.tablePath].filters
);
}
}
}, [userDoc.state, tableState?.tablePath]);
const filterColumns = _sortBy(Object.values(tableState!.columns), "index")
.filter(c => !UNFILTERABLES.includes(c.type))
.map(c => ({
key: c.key,
label: c.name,
@@ -212,7 +234,11 @@ const Filters = ({ columns, setFilters }: any) => {
multiple
freeText={false}
onChange={value => setQuery(query => ({ ...query, value }))}
options={selectedColumn.config.options ?? []}
options={
selectedColumn.config.options
? selectedColumn.config.options.sort()
: []
}
label=""
value={Array.isArray(query?.value) ? query.value : []}
TextFieldProps={{ hiddenLabel: true }}
@@ -226,7 +252,11 @@ const Filters = ({ columns, setFilters }: any) => {
onChange={value => {
if (value !== null) setQuery(query => ({ ...query, value }));
}}
options={selectedColumn.config.options ?? []}
options={
selectedColumn.config.options
? selectedColumn.config.options.sort()
: []
}
label=""
value={typeof query?.value === "string" ? query.value : null}
TextFieldProps={{ hiddenLabel: true }}
@@ -239,7 +269,11 @@ const Filters = ({ columns, setFilters }: any) => {
multiple
onChange={value => setQuery(query => ({ ...query, value }))}
value={query.value as string[]}
options={selectedColumn.config.options}
options={
selectedColumn.config.options
? selectedColumn.config.options.sort()
: []
}
label={""}
searchable={false}
freeText={true}
@@ -273,19 +307,38 @@ const Filters = ({ columns, setFilters }: any) => {
break;
}
};
const handleUpdateFilters = (filters: FireTableFilter[]) => {
userDoc.dispatch({
action: DocActions.update,
data: {
tables: { [`${tableState?.tablePath}`]: { filters } },
},
});
};
return (
<>
<Button
variant="outlined"
color="primary"
onClick={handleClick}
startIcon={<FilterIcon />}
>
{filters.length !== 0 && filters.length}
{" Filter"}
{filters.length > 1 && "s"}
</Button>
<Grid container direction="row">
<Button
variant="outlined"
color="primary"
onClick={handleClick}
startIcon={<FilterIcon />}
>
{filters.length !== 0 && filters.length}
{" Filter"}
{filters.length > 1 && "s"}
</Button>
{(tableState?.filters ?? []).map(filter => (
<Chip
key={filter.key}
label={`${filter.key} ${filter.operator} ${filter.value}`}
onDelete={() => {
handleUpdateFilters([]);
}}
/>
))}
</Grid>
<Popover
id={id}
open={open}
@@ -404,7 +457,7 @@ const Filters = ({ columns, setFilters }: any) => {
<Button
disabled={query.key === ""}
onClick={() => {
setFilters([]);
handleUpdateFilters([]);
setQuery({
key: "",
operator: "",
@@ -420,7 +473,7 @@ const Filters = ({ columns, setFilters }: any) => {
disabled={query.value === null || query.value === undefined}
color="primary"
onClick={() => {
setFilters([query]);
handleUpdateFilters([query]);
handleClose();
}}
>

View File

@@ -8,7 +8,6 @@ import {
MenuItem,
Typography,
Button,
Tooltip,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
@@ -16,7 +15,6 @@ import Filters from "./Filters";
import ImportCSV from "./ImportCSV";
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";
@@ -59,7 +57,6 @@ const useStyles = makeStyles(theme =>
interface ITableHeaderProps {
rowHeight: number;
updateConfig: Function;
filters: FireTableFilter[];
}
/**
@@ -68,8 +65,6 @@ interface ITableHeaderProps {
export default function TableHeader({
rowHeight,
updateConfig,
filters,
}: ITableHeaderProps) {
const classes = useStyles();
const { tableActions, tableState } = useFiretableContext();
@@ -111,11 +106,7 @@ export default function TableHeader({
<Grid item />
<Grid item>
<Filters
columns={tempColumns}
tableFilters={filters}
setFilters={tableActions?.table.filter}
/>
<Filters />
</Grid>
<Grid item xs className={classes.spacer} />
@@ -159,11 +150,9 @@ export default function TableHeader({
<Grid item />
{/* {userClaims && userClaims.roles?.includes("ADMIN") && ( */}
<Grid item>
<ImportCSV />
</Grid>
{/* )} */}
<Grid item>
<ExportCSV />

View File

@@ -2,7 +2,7 @@ import React from "react";
import clsx from "clsx";
import { CustomCellProps } from "./withCustomCell";
import { makeStyles, createStyles, Grid } from "@material-ui/core";
import { makeStyles, createStyles, Grid, Tooltip } from "@material-ui/core";
import MultiSelect_ from "@antlerengineering/multiselect";
import FormattedChip, { VARIANTS } from "components/FormattedChip";
@@ -94,41 +94,62 @@ export default function MultiSelect({
};
return (
<MultiSelect_
value={
value === undefined || value === null ? (isSingle ? null : []) : value
<Tooltip
title={
value
? Array.isArray(value)
? value.length > 1
? value.join(", ")
: ``
: value
: ``
}
onChange={onSubmit}
freeText={!isSingle && config.freeText}
multiple={!isSingle as any}
label={column.name}
labelPlural={column.name}
options={config.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
}
/>
enterDelay={100}
interactive
placement="bottom-start"
>
<div>
<MultiSelect_
value={
value === undefined || value === null
? isSingle
? null
: []
: value
}
onChange={onSubmit}
freeText={!isSingle && config.freeText}
multiple={!isSingle as any}
label={column.name}
labelPlural={column.name}
options={config.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
}
/>
</div>
</Tooltip>
);
}

View File

@@ -29,6 +29,7 @@ export default function SubTable({ column, row }: CustomCellProps) {
}, "")
: "";
const fieldName = column.key as string;
const documentCount = row[fieldName]?.count ?? "";
const router = useRouter();
const parentLabels = queryString.parse(router.location.search).parentLabel;
@@ -54,7 +55,7 @@ export default function SubTable({ column, row }: CustomCellProps) {
className={clsx("cell-collapse-padding", classes.root)}
>
<Grid item xs className={classes.labelContainer}>
{column.name}: {label}
{documentCount} {column.name}: {label}
</Grid>
<Grid item>

View File

@@ -120,7 +120,7 @@ export default function Table({ collection, filters }: ITableProps) {
key: "new",
name: "Add column",
type: FieldType.last,
index: columns.length,
index: columns.length ?? 0,
width: 160,
headerRenderer: FinalColumnHeader,
cellClass: finalColumnClasses.cell,
@@ -152,7 +152,6 @@ export default function Table({ collection, filters }: ITableProps) {
<TableHeader
rowHeight={rowHeight}
updateConfig={tableActions.table.updateConfig}
filters={filters}
/>
{!tableState.loadingColumns ? (

View File

@@ -18,7 +18,6 @@ export type Table = {
name: string;
roles: string[];
description: string;
regional: boolean;
section: string;
};

View File

@@ -6,7 +6,7 @@ import equals from "ramda/es/equals";
import firebase from "firebase/app";
import { FireTableFilter, FiretableOrderBy } from ".";
import { SnackContext } from "../../contexts/snackContext";
import { cloudFunction } from "../../firebase/callables";
const CAP = 1000; // safety paramter sets the upper limit of number of docs fetched by this hook
const serverTimestamp = firebase.firestore.FieldValue.serverTimestamp;
var characters =
@@ -140,12 +140,24 @@ const useTable = (initialOverrides: any) => {
),
});
} else if (error.code === "permission-denied") {
snack.open({
position: { horizontal: "center", vertical: "top" },
severity: "error",
message: "You don't have permissions to see the results.",
duration: 10000,
});
if (filters.length === 0) {
cloudFunction(
"callable-setFiretablePersonalizedFilter",
{
table: tableState.path,
},
resp => {
console.log(resp);
},
() => {}
);
} else
snack.open({
position: { horizontal: "center", vertical: "top" },
severity: "error",
message: "You don't have permissions to see the results.",
duration: 10000,
});
}
}
);

View File

@@ -78,11 +78,6 @@ const useStyles = makeStyles(theme =>
})
);
const regionalFilter = (regional, userClaims) =>
regional && userClaims?.regions && !userClaims?.regions?.includes("GL")
? `?filters=%5B%7B%22key%22%3A%22region%22%2C%22operator%22%3A%22%3D%3D%22%2C%22value%22%3A%22${userClaims?.regions[0]}%22%7D%5D`
: "";
const TablesView = () => {
const classes = useStyles();
@@ -142,10 +137,7 @@ const TablesView = () => {
}
bodyContent={table.description}
primaryLink={{
to: `${routes.table}/${table.collection}${regionalFilter(
table?.regional ?? false,
userClaims
)}`,
to: `${routes.table}/${table.collection}`,
label: "Open",
}}
secondaryAction={