Merge branch 'master' into develop

* master: (46 commits)
  derivative column field renderer
  derivative column config UI
  reposition table fav button
  noreferrer typo
  fix connect table field configuration
  fix: csv import supporting the new columns structure
  fix:adding new columns to the end of the table
  fix: prevent table settings from crashing when roles are empty
  fix: update column options mapping in filters
  create and update table settings
  fix sideDrawer single select
  fix side drawer
  column restructure migration button
  update table connect popper
  tableConnect and subtable columns settings
  column setttings setup+ single/multiselect fields
  delete github workflow
  docker using yarn
  dockerfile test2
  docker typo
  ...
This commit is contained in:
Sidney Alcantara
2020-07-10 13:55:37 +10:00
60 changed files with 2056 additions and 508 deletions

View File

@@ -1,13 +1,5 @@
# Stage 1
FROM node:8 as react-build
WORKDIR /www
COPY . ./
RUN yarn
RUN yarn build
# Stage 2 - the production environment
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=react-build /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
FROM node:10.15.1
WORKDIR cloud_functions/functions
COPY cloud_functions/functions/package.json ./
RUN yarn install
COPY . .

View File

@@ -20,12 +20,15 @@ if (serviceAccount) {
});
const db = admin.firestore();
const main = async deployRequestPath => {
await db.doc(deployRequestPath).update({ deployedAt: serverTimestamp() });
const main = async (deployRequestPath: string, currentBuild) => {
await db.doc(deployRequestPath).update({
deployedAt: serverTimestamp(),
currentBuild: currentBuild ?? "",
});
return true;
};
main(process.argv[2])
main(process.argv[2], process.argv[3])
.catch(err => console.log(err))
.then(() => console.log("this will succeed"))
.catch(() => "obligatory catch");

View File

@@ -1,4 +1,9 @@
steps:
# - name: "gcr.io/kaniko-project/executor:latest"
# args:
# - --destination=gcr.io/$PROJECT_ID/image
# - --cache=true
# - --cache-ttl=24h
- name: gcr.io/cloud-builders/gcloud
args:
- kms
@@ -34,6 +39,7 @@ steps:
args:
- "updateStatus"
- "${_REQUEST_DOC_PATH}"
- "${_FUNCTION_CONFIG}"
dir: "cloud_functions/functions"
substitutions:

1
firetable Submodule

Submodule firetable added at 655a4ec398

View File

@@ -4,7 +4,7 @@
"private": true,
"dependencies": {
"@antlerengineering/components": "^0.2.7",
"@antlerengineering/multiselect": "^0.3.9",
"@antlerengineering/multiselect": "^0.3.14",
"@date-io/date-fns": "1.x",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",

View File

@@ -16,30 +16,30 @@ export default function Firetable(props: SvgIconProps) {
const strokeColor = "#fff";
return (
<svg width="140" height="32" viewBox="0 0 140 32">
<g fill="none" fill-rule="evenodd">
<g fill="none" fillRule="evenodd">
<rect width="32" height="32" fill={primaryColor} rx="2.667" />
<g fill={strokeColor}>
<g fill-rule="nonzero">
<g fillRule="nonzero">
<path d="M5.333 18.667V24A2.667 2.667 0 0 0 8 26.667h5.333v-8h-8zM6.667 20H12v5.333H8A1.333 1.333 0 0 1 6.67 24.1l-.003-.1v-4zM18.667 18.667v8H24A2.667 2.667 0 0 0 26.667 24v-5.333h-8zM20 25.333V20h5.333v4c0 .703-.544 1.279-1.233 1.33l-.1.003h-4z" />
<path d="M12 18.667v8h8v-8h-8zM13.333 20h5.334v5.333h-5.334V20z" />
</g>
<path
fill-rule="nonzero"
fillRule="nonzero"
d="M18.667 12v8h8v-8h-8zM20 13.333h5.333v5.334H20v-5.334z"
/>
<path
fill-rule="nonzero"
fillRule="nonzero"
d="M12 12v8h8v-8h-8zm1.333 1.333h5.334v5.334h-5.334v-5.334z"
/>
<path
stroke={strokeColor}
stroke-width="1.333"
strokeWidth="1.333"
d="M19.333 6v6.667H26V6h-6.667zm2.577.667h1.512L25.672 12h-2.143l-.85-2.075L21.826 12h-2.165l2.248-5.333z"
/>
</g>
<path
fill={primaryColor}
fill-rule="nonzero"
fillRule="nonzero"
d="M43.467 26v-9.813h2.586V13.36h-2.586v-2.693c0-1.547 1.093-2.267 2.16-2.267 1.2 0 2.053.693 2.053 1.68v.187c0 .906.693 1.573 1.573 1.573s1.627-.693 1.627-1.573v-.294c0-2.24-1.787-4.453-5.253-4.453-2.987 0-5.36 1.76-5.36 4.88v2.96H37.76v2.827h2.507V26h3.2zm7.413 0V13.36h-3.2V26h3.2zm6.773 0v-8.267s.88-1.706 2.827-1.706c.933 0 1.52.293 1.52.293l1.12-2.88s-1.04-.4-2.187-.4c-2.346 0-3.44 2.16-3.44 2.16v-1.84h-3.04V26h3.2zm13.067.32c3.067 0 5.067-1.813 5.067-1.813l-1.6-2.4-.015.014c-.146.14-1.377 1.266-3.452 1.266-2 0-3.307-1.44-3.547-2.56h9.44c.027-.48.027-.96.027-1.2 0-3.894-2.907-6.587-6.373-6.587-3.654 0-6.347 2.933-6.347 6.64s2.96 6.64 6.8 6.64zm2.72-7.813h-6.267c.107-1.12 1.04-2.56 3.094-2.56 2.106 0 3.12 1.466 3.173 2.56zm11.333 7.813c1.84 0 3.04-.827 3.04-.827L86.64 22.88s-.613.453-1.333.453c-.854 0-1.654-.453-1.654-2V16.24h3.814v-2.88h-3.814V9.6h-3.2v3.76h-2.48v2.88h2.48v5.6c0 2.693 1.867 4.48 4.32 4.48zm9.28 0c2.854 0 4.16-1.92 4.16-1.92V26h2.96v-8.24c0-2.88-2.133-4.72-5.76-4.72-2.64 0-4.986 1.147-4.986 1.147l1.04 2.64s1.946-.934 3.786-.934c1.12 0 2.72.534 2.72 2.187v.72s-1.2-.907-3.52-.907c-2.746 0-4.96 1.76-4.96 4.187 0 2.693 2.187 4.24 4.56 4.24zm.987-2.667c-.987 0-2.293-.293-2.293-1.626 0-1.36 1.36-1.787 2.346-1.787 1.734 0 2.88.64 2.88.64v1.307s-1.013 1.466-2.933 1.466zm16.48 2.667c3.28 0 6.16-2.747 6.16-6.64s-2.88-6.64-6.16-6.64c-2.4 0-3.573 1.653-3.573 1.653V5.84h-3.2V26h2.96v-1.653s1.093 1.973 3.813 1.973zm-.56-2.987c-2.107 0-3.013-1.76-3.013-1.76v-3.786s.906-1.76 3.013-1.76c2.16 0 3.467 1.573 3.467 3.653s-1.307 3.653-3.467 3.653zM123.6 26V5.84h-3.2V26h3.2zm9.52.32c3.067 0 5.067-1.813 5.067-1.813l-1.6-2.4-.015.014c-.146.14-1.377 1.266-3.452 1.266-2 0-3.307-1.44-3.547-2.56h9.44c.027-.48.027-.96.027-1.2 0-3.894-2.907-6.587-6.373-6.587-3.654 0-6.347 2.933-6.347 6.64s2.96 6.64 6.8 6.64zm2.72-7.813h-6.267c.107-1.12 1.04-2.56 3.094-2.56 2.106 0 3.12 1.466 3.173 2.56z"
/>
</g>

View File

@@ -0,0 +1,10 @@
import React from "react";
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon";
export default function FileDownload(props: SvgIconProps) {
return (
<SvgIcon viewBox="0 0 24 24" {...props}>
<path d="M23 15h-2v2h2v-2zm0-4h-2v2h2v-2zm0 8h-2v2c1 0 2-1 2-2zM15 3h-2v2h2V3zm8 4h-2v2h2V7zm-2-4v2h2c0-1-1-2-2-2zM3 21h8v-6H1v4c0 1.1.9 2 2 2zM3 7H1v2h2V7zm12 12h-2v2h2v-2zm4-16h-2v2h2V3zm0 16h-2v2h2v-2zM3 3C2 3 1 4 1 5h2V3zm0 8H1v2h2v-2zm8-8H9v2h2V3zM7 3H5v2h2V3z" />
</SvgIcon>
);
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon";
export default function FileDownload(props: SvgIconProps) {
return (
<SvgIcon viewBox="-2 -2 24 24" {...props}>
<path
fillRule="nonzero"
d="M9 0a2 2 0 012 2v16a2 2 0 01-2 2H0V0h9M2 8v4h7V8H2m0 6v4h7v-4H2M2 2v4h7V2H2m11 7h3V6h2v3h3v2h-3v3h-2v-3h-3V9z"
/>
</SvgIcon>
);
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon";
export default function FileDownload(props: SvgIconProps) {
return (
<SvgIcon viewBox="-2 -2 24 24" {...props}>
<path
fillRule="nonzero"
d="M12 0a2 2 0 00-2 2v16a2 2 0 002 2h9V0h-9m7 8v4h-7V8h7m0 6v4h-7v-4h7m0-12v4h-7V2h7M8 9H5V6H3v3H0v2h3v3h2v-3h3V9z"
/>
</SvgIcon>
);
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon";
export default function FileDownload(props: SvgIconProps) {
return (
<SvgIcon viewBox="-2 -2 24 24" {...props}>
<path
fillRule="nonzero"
d="M2 0h7a2 2 0 012 2v16a2 2 0 01-2 2H2a2 2 0 01-2-2V2a2 2 0 012-2m0 8v4h7V8H2m0 6v4h7v-4H2M2 2v4h7V2H2m13.59 8L13 7.41 14.41 6 17 8.59 19.59 6 21 7.41 18.41 10 21 12.59 19.59 14 17 11.41 14.41 14 13 12.59 15.59 10z"
/>
</SvgIcon>
);
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon";
export default function FileDownload(props: SvgIconProps) {
return (
<SvgIcon viewBox="-2 -2 24 24" {...props}>
<path
d="M18 0a2 2 0 012 2v16a2 2 0 01-2 2H2a2 2 0 01-2-2V2A2 2 0 011.135.196l.047-.074.051.03C1.469.054 1.728 0 2 0h16zm0 14h-7v4h7v-4zm-16 .992V18h4.813L2 14.992zM9 14H4.187L9 17.008V14zm9-6h-7v4h7V8zM2 8.992V12h4.813L2 8.992zM9 8H4.187L9 11.007V8zm9-6h-7v4h7V2zM2 2.992V6h4.814L2 2.992zM9 2H4.188L9 5.007V2z"
fillRule="nonzero"
/>
</SvgIcon>
);
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon";
export default function FileDownload(props: SvgIconProps) {
return (
<SvgIcon viewBox="-2 -2 24 24" {...props}>
<path
d="M2 20a2 2 0 01-2-2V2a2 2 0 012-2h16a2 2 0 012 2v16a2 2 0 01-2 2H2zm16-6h-7v4h7v-4zm-9 0H2v4h7v-4zm9-6h-7v4h7V8zM9 8H2v4h7V8zm9-6h-7v4h7V2zM9 2H2v4h7V2z"
fillRule="nonzero"
/>
</SvgIcon>
);
}

View File

@@ -42,13 +42,14 @@ export default function PopupContents({
multiple = true,
row,
}: IPopupContentsProps) {
const index = collectionPath ?? config.index; //temporary for pre column restructure migration
const classes = useStyles();
const { userClaims } = useFiretableContext();
const algoliaIndex = useMemo(() => {
return searchClient.initIndex(collectionPath);
}, [collectionPath]);
return searchClient.initIndex(index);
}, [index]);
// Algolia search query
const [query, setQuery] = useState("");
@@ -62,11 +63,10 @@ export default function PopupContents({
console.log("SEARCH", query, algoliaIndex, row);
const data = { ...userClaims, ...row };
const filters = config.filters.replace(
/\{\{(.*?)\}\}/g,
(m, k) => data[k]
);
console.log(filters);
const filters = config?.filters
? config?.filters.replace(/\{\{(.*?)\}\}/g, (m, k) => data[k])
: "";
const resp = await algoliaIndex.search(query, {
filters,
});
@@ -86,7 +86,7 @@ export default function PopupContents({
const { _highlightResult, ...snapshot } = hit;
const output = {
snapshot,
docPath: `${collectionPath}/${snapshot.objectID}`,
docPath: `${index}/${snapshot.objectID}`,
};
if (multiple) onChange([...value, output]);

View File

@@ -1,136 +0,0 @@
import React, { useState, useEffect } from "react";
import _camelCase from "lodash/camelCase";
import AddIcon from "@material-ui/icons/Add";
import useRouter from "../hooks/useRouter";
import MultiSelect from "@antlerengineering/multiselect";
import {
Tooltip,
Fab,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
Button,
Select,
} from "@material-ui/core";
import { useFiretableContext } from "../contexts/firetableContext";
export interface ICreateTableDialogProps {
/** Optional class overrides */
classes?: Partial<Record<"fab", string>>;
/** Function to handle table creation */
createTable: (tableName: string, collectionName: string) => void;
}
export default function CreateTableDialog({
classes = {},
createTable,
}: ICreateTableDialogProps) {
const router = useRouter();
const { userClaims } = useFiretableContext();
const [open, setOpen] = React.useState(false);
const [tableName, setTableName] = useState("");
const [collectionName, setCollectionName] = useState("");
useEffect(() => {
setCollectionName(_camelCase(tableName));
}, [tableName]);
function handleClickOpen() {
setOpen(true);
}
function handleClose() {
setTableName("");
setCollectionName("");
setOpen(false);
}
function handleCreate() {
createTable(tableName, collectionName);
if (router.location.pathname === "/") {
router.history.push(`table/${collectionName}`);
} else {
router.history.push(collectionName);
}
handleClose();
}
if (userClaims && userClaims.roles?.includes("ADMIN"))
return (
<div>
<Tooltip title="Create a table">
<Fab
className={classes.fab}
color="secondary"
aria-label="Create table"
onClick={handleClickOpen}
>
<AddIcon />
</Fab>
</Tooltip>
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="form-dialog-title"
>
<DialogTitle id="form-dialog-title">New table</DialogTitle>
<DialogContent>
<DialogContentText>Create a new Table</DialogContentText>
<Select label="Section" />
<TextField
autoFocus
onChange={e => {
setTableName(e.target.value);
}}
margin="dense"
id="name"
label="Table Name"
type="text"
fullWidth
variant="filled"
/>
<TextField
value={collectionName}
onChange={e => {
setCollectionName(e.target.value);
}}
variant="filled"
margin="dense"
id="collection"
label="Collection Name"
type="text"
fullWidth
/>
<TextField
label="Description"
id="description"
variant="filled"
fullWidth
multiline={true}
/>
<MultiSelect
freeText={true}
options={["PROGRAM", "RECRUITING", "COACHING", "ADMIN"]}
multiple
value={[]}
selectAll
label="Roles"
onChange={() => {}}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
<Button onClick={handleCreate} color="primary">
Create
</Button>
</DialogActions>
</Dialog>
</div>
);
else return <></>;
}

View File

@@ -21,7 +21,15 @@ export default function Autosave({ values, errors }: IAutosaveProps) {
const { tableState, sideDrawerRef } = useFiretableContext();
const getEditables = value =>
_pick(value, tableState?.columns?.map(c => c.key) ?? []);
_pick(
value,
(tableState &&
(Array.isArray(tableState?.columns)
? tableState?.columns
: Object.values(tableState?.columns)
).map(c => c.key)) ??
[]
);
const [debouncedValue] = useDebounce(getEditables(values), 1000, {
equalityFn: _isEqual,

View File

@@ -44,8 +44,8 @@ function Action({
field,
form,
editable,
callableName,
}: FieldProps<any> & { callableName: string; editable?: boolean }) {
config,
}: FieldProps<any> & { config: { callableName: string }; editable?: boolean }) {
const classes = useStyles();
const { ref, ...docData } = form.values;
@@ -57,7 +57,7 @@ function Action({
console.log("RUN");
cloudFunction(
callableName,
config.callableName,
{
ref: { path: ref.path, id: ref.id },
row: sanitiseRowData(Object.assign({}, docData)),
@@ -69,7 +69,7 @@ function Action({
if (cellValue) form.setFieldValue(field.name, cellValue);
},
error => {
console.error("ERROR", callableName, error);
console.error("ERROR", config.callableName, error);
setIsRunning(false);
snack.open({ message: JSON.stringify(error), severity: "error" });
}
@@ -93,14 +93,14 @@ function Action({
<a
href={field.value.status}
target="_blank"
rel="noopener noreferer"
rel="noopener noreferrer"
>
{field.value.status}
</a>
) : hasRan ? (
field.value.status
) : (
sanitiseCallableName(callableName)
sanitiseCallableName(config.callableName)
)}
</Typography>
</Grid>

View File

@@ -10,8 +10,13 @@ export default function MultiSelect({
field,
form,
editable,
config,
...props
}: FieldProps<string[]> & MultiSelectProps<string> & { editable?: boolean }) {
}: FieldProps<string[]> &
MultiSelectProps<string> & {
config: { options: string[] };
editable?: boolean;
}) {
const theme = useTheme();
const handleDelete = (index: number) => () => {
@@ -24,6 +29,7 @@ export default function MultiSelect({
<>
<MultiSelectA
{...props}
options={config.options ?? []}
multiple
value={field.value ? field.value : []}
onChange={value => form.setFieldValue(field.name, value)}

View File

@@ -14,8 +14,13 @@ export default function SingleSelect({
field,
form,
editable,
config,
...props
}: FieldProps<string> & MultiSelectProps<string> & { editable: boolean }) {
}: FieldProps<string> &
MultiSelectProps<string> & {
config: { options: string[] };
editable: boolean;
}) {
const theme = useTheme();
const handleChange = value => form.setFieldValue(field.name, value);
@@ -24,6 +29,7 @@ export default function SingleSelect({
<>
<MultiSelect
{...props}
options={config.options}
multiple={false}
value={field.value}
onChange={handleChange}

View File

@@ -35,33 +35,34 @@ export default function SubTable({
form,
field,
label,
parentLabel,
}: FieldProps<any> & { parentLabel?: string; label: string }) {
config,
}: FieldProps<any> & { config: { parentLabel?: string[] }; label: string }) {
const classes = useStyles();
const router = useRouter();
const parentLabels = queryString.parse(router.location.search).parentLabel;
const _label = config?.parentLabel
? config.parentLabel.reduce((acc, curr) => {
if (acc !== "") return `${acc} - ${form.values[curr]}`;
else return form.values[curr];
}, "")
: "";
let subTablePath = "";
if (parentLabels)
subTablePath =
encodeURIComponent(`${form.values.ref.path}/${field.name}`) +
`?parentLabel=${parentLabels},${
parentLabel ? form.values[parentLabel] : ""
}`;
`?parentLabel=${parentLabels},${label}`;
else
subTablePath =
encodeURIComponent(`${form.values.ref.path}/${field.name}`) +
`?parentLabel=${
parentLabel ? encodeURIComponent(form.values[parentLabel]) : ""
}`;
`?parentLabel=${encodeURIComponent(_label)}`;
return (
<Grid container wrap="nowrap">
<Grid container alignItems="center" className={classes.labelContainer}>
<Typography variant="body1">
{label}
{parentLabel && `: ${form.values[parentLabel]}`}
{`: ${_label}`}
</Typography>
</Grid>

View File

@@ -23,7 +23,7 @@ export default function Url(props: TextFieldProps) {
component="a"
href={props.field.value}
target="_blank"
rel="noopener noreferer"
rel="noopener noreferrer"
style={{ width: 56 }}
disabled={!props.field.value}
>

View File

@@ -91,48 +91,48 @@ export type Field = {
};
export type Fields = (Field | ((values: Values) => Field))[];
const initializeValue = type => {
switch (type) {
case FieldType.singleSelect:
case FieldType.multiSelect:
case FieldType.image:
case FieldType.file:
return [];
case FieldType.date:
case FieldType.dateTime:
return null;
case FieldType.checkbox:
return false;
case FieldType.number:
return 0;
case FieldType.json:
return {};
break;
case FieldType.shortText:
case FieldType.longText:
case FieldType.email:
case FieldType.phone:
case FieldType.url:
case FieldType.code:
case FieldType.richText:
default:
break;
}
};
const getInitialValues = (fields: Fields): Values =>
fields.reduce((acc, _field) => {
const field = _isFunction(_field) ? _field({}) : _field;
if (!field.name) return acc;
let value: any = "";
switch (field.type) {
case FieldType.singleSelect:
case FieldType.multiSelect:
case FieldType.image:
case FieldType.file:
value = [];
break;
case FieldType.date:
case FieldType.dateTime:
value = null;
break;
case FieldType.checkbox:
value = false;
break;
case FieldType.number:
value = 0;
break;
case FieldType.json:
value = {};
break;
case FieldType.shortText:
case FieldType.longText:
case FieldType.email:
case FieldType.phone:
case FieldType.url:
case FieldType.code:
case FieldType.richText:
default:
break;
let _type = field.type;
if (field.config && field.config.renderFieldType) {
_type = field.config.renderFieldType;
}
const value = initializeValue(_type);
return { ...acc, [field.name]: value };
}, {});
@@ -179,13 +179,16 @@ export default function Form({ fields, values }: IFormProps) {
? _field(values)
: _field;
const { type, ...fieldProps } = field;
let _type = type;
if (field.config && field.config.renderFieldType) {
_type = field.config.renderFieldType;
}
// TODO: handle get initial field value for when a field is later
// shown to prevent uncontrolled components becoming controlled
let renderedField: React.ReactNode = null;
switch (type) {
switch (_type) {
case FieldType.shortText:
case FieldType.longText:
case FieldType.email:
@@ -336,7 +339,7 @@ export default function Form({ fields, values }: IFormProps) {
return (
<FieldWrapper
key={fieldProps.name ?? i}
type={type}
type={_type}
name={field.name}
label={field.label}
>

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import clsx from "clsx";
import _isNil from "lodash/isNil";
import _sortBy from "lodash/sortBy";
import _findIndex from "lodash/findIndex";
import { Drawer, Fab } from "@material-ui/core";
@@ -53,54 +54,50 @@ export default function SideDrawer() {
};
// Map columns to form fields
const fields = tableState?.columns?.map(column => {
const field: Field = {
type: column.type,
name: column.key,
label: column.name,
};
switch (column.type) {
case FieldType.longText:
field.fieldVariant = "long";
break;
const fields =
tableState?.columns &&
(Array.isArray(tableState?.columns)
? tableState?.columns
: _sortBy(Object.values(tableState?.columns), "index")
).map(column => {
const field: Field = {
type: column.type,
name: column.key,
label: column.name,
};
case FieldType.email:
field.fieldVariant = "email";
break;
switch (column.type) {
case FieldType.longText:
field.fieldVariant = "long";
break;
case FieldType.phone:
field.fieldVariant = "phone";
break;
case FieldType.email:
field.fieldVariant = "email";
break;
case FieldType.number:
field.fieldVariant = "number";
break;
case FieldType.phone:
field.fieldVariant = "phone";
break;
case FieldType.singleSelect:
case FieldType.multiSelect:
field.options = column.options;
break;
case FieldType.number:
field.fieldVariant = "number";
break;
case FieldType.connectTable:
field.collectionPath = column.collectionPath;
field.config = column.config;
break;
case FieldType.singleSelect:
case FieldType.multiSelect:
case FieldType.connectTable:
case FieldType.subTable:
case FieldType.action:
field.config = column.config;
break;
case FieldType.subTable:
field.parentLabel = column.parentLabel;
break;
case FieldType.action:
field.callableName = column.callableName;
break;
default:
break;
}
field.editable = column.editable;
return field;
});
default:
break;
}
field.editable = column.editable;
return field;
});
return (
<div className={clsx(open && classes.open, disabled && classes.disabled)}>

View File

@@ -66,6 +66,7 @@ interface StyledCardProps {
label?: string;
};
secondaryAction?: React.ReactNode;
headerAction?: React.ReactNode;
}
export default function StyledCard({
@@ -77,6 +78,7 @@ export default function StyledCard({
primaryButton,
primaryLink,
secondaryAction,
headerAction,
}: StyledCardProps) {
const classes = useStyles();
@@ -107,11 +109,14 @@ export default function StyledCard({
{overline}
</Typography>
)}
{title && (
<Typography variant="h5" className={classes.title}>
{title}
</Typography>
)}
<Grid container direction="row" justify="space-between">
{title && (
<Typography variant="h5" className={classes.title}>
{title}
</Typography>
)}
{headerAction && headerAction}
</Grid>
</Grid>
{imageSource && (

View File

@@ -65,13 +65,14 @@ const useStyles = makeStyles(theme =>
})
);
// TODO: REMOVE THIS OLD COMPONENT
export default function ColumnEditor() {
const {
tableState,
tableActions,
selectedColumnHeader,
setSelectedColumnHeader,
} = useFiretableContext();
} = useFiretableContext() as any;
const actions = tableActions!.column;
const { column, anchorEl } = selectedColumnHeader ?? {};
@@ -176,7 +177,7 @@ export default function ColumnEditor() {
]);
const deleteColumn = () => {
actions.remove(configColumnIndex);
actions.remove(column?.key);
handleClose();
clearValues();
};

View File

@@ -90,17 +90,21 @@ const ColumnHeader: Column<any>["headerRenderer"] = ({ column }) => {
const classes = useStyles();
const {
setSelectedColumnHeader,
tableState,
tableActions,
userClaims,
columnMenuRef,
} = useFiretableContext();
if (!setSelectedColumnHeader || !tableState || !tableActions) return null;
if (!columnMenuRef || !tableState || !tableActions) return null;
const { orderBy } = tableState;
const handleClick = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => setSelectedColumnHeader({ column, anchorEl: event.currentTarget });
) =>
columnMenuRef?.current?.setSelectedColumnHeader({
column,
anchorEl: event.currentTarget,
});
const isSorted = orderBy?.[0]?.key === (column.key as string);
const isAsc = isSorted && orderBy?.[0]?.direction === "asc";

View File

@@ -0,0 +1,52 @@
import React from "react";
import { createStyles, makeStyles } from "@material-ui/core/styles";
import Grid from "@material-ui/core/Grid";
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/theme-monokai";
const useStyles = makeStyles(Theme =>
createStyles({
root: {},
})
);
const heightCalc = (code: string, minHeight: number, maxHeight: number) => {
const codeHeight = code.split("\n").length * 20;
console.log({ codeHeight });
if (codeHeight <= maxHeight && codeHeight >= minHeight) return codeHeight;
else if (codeHeight > maxHeight) return maxHeight;
else if (codeHeight < minHeight) return minHeight;
};
export default function CodeEditor(props: any) {
const { handleChange, script } = props;
console.log({ script });
const classes = useStyles();
return (
<Grid container direction="column" className={classes.root}>
<AceEditor
key={`column-code-editor`}
placeholder="insert code"
mode="javascript"
theme="monokai"
name={"code-editor"}
//onLoad={this.onLoad}
onChange={handleChange}
fontSize={14}
showPrintMargin={true}
height={`${heightCalc(script ?? "", 100, 600)}px`}
//showGutter={true}
highlightActiveLine={true}
value={script}
setOptions={{
enableBasicAutocompletion: false,
enableLiveAutocompletion: false,
enableSnippets: false,
showLineNumbers: true,
tabSize: 2,
}}
/>
</Grid>
);
}

View File

@@ -0,0 +1,50 @@
import React, { useState, useEffect } from "react";
import { FieldType } from "constants/fields";
import MultiSelect from "@antlerengineering/multiselect";
import { db } from "../../../../firebase";
const ColumnSelector = ({
tableColumns,
handleChange,
validTypes,
table,
value,
label,
}: {
tableColumns?: any[];
handleChange: any;
validTypes?: FieldType[];
table?: string;
value: any;
label?: string;
}) => {
const [columns, setColumns] = useState(tableColumns ?? []);
const getColumns = async table => {
const tableConfigDoc = await db
.doc(`_FIRETABLE_/settings/schema/${table}`)
.get();
const tableConfig = tableConfigDoc.data();
if (tableConfig) setColumns(tableConfig.columns ?? []);
};
useEffect(() => {
if (table) {
console.log({ table });
getColumns(table);
}
}, [table]);
const options = columns
? Object.values(columns)
.filter(col => (validTypes ? validTypes.includes(col.type) : true))
.map(col => ({ value: col.key, label: col.name }))
: [];
return (
<MultiSelect
label={label}
onChange={handleChange}
value={value ?? []}
options={options}
/>
);
};
export default ColumnSelector;

View File

@@ -0,0 +1,90 @@
import React, { useEffect, useState } from "react";
import { createStyles, makeStyles } 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 _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",
flexWrap: "wrap",
maxWidth: "95%",
padding: Theme.spacing(1),
},
chip: {
margin: Theme.spacing(0.5),
},
})
);
export default function OptionsInput(props: any) {
const { options, handleChange } = props;
const classes = useStyles();
const [newOption, setNewOption] = useState("");
const handleAdd = () => {
// setOptions([...options, newOption]);
if (newOption !== "") {
handleChange([...options, newOption]);
setNewOption("");
}
};
const handleDelete = (optionToDelete: string) => () =>
handleChange(options.filter((option: string) => option !== optionToDelete));
return (
<Grid container direction="column" className={classes.root}>
<Grid item>
<TextField
value={newOption}
className={classes.field}
label={props.placeholder ?? "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="add new"
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

@@ -0,0 +1,41 @@
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 = (props: { value: FieldType | null; onChange: any }) => {
const { value, onChange } = props;
return (
<TextField
fullWidth
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

@@ -0,0 +1,119 @@
import React from "react";
import clsx from "clsx";
import {
createStyles,
makeStyles,
MenuItem,
ListItemIcon,
ListSubheader,
Divider,
} from "@material-ui/core";
const useStyles = makeStyles(theme =>
createStyles({
subheader: {
...theme.typography.overline,
color: theme.palette.text.disabled,
padding: theme.spacing(1, 1.25),
paddingTop: theme.spacing(2) + 1,
cursor: "default",
userSelect: "none",
"&:focus": { outline: 0 },
},
menuItem: {
minHeight: 42,
padding: theme.spacing(0.75, 1.25),
...theme.typography.h6,
fontSize: "0.875rem",
color: theme.palette.text.secondary,
transition: theme.transitions.create(["background-color", "color"], {
duration: theme.transitions.duration.shortest,
}),
"&:hover": {
backgroundColor: theme.palette.text.primary,
color: "#f1f1f3",
},
},
menuItemIcon: {
minWidth: 24,
marginRight: theme.spacing(1.25),
color: "inherit",
},
menuItemActive: { color: theme.palette.text.primary },
menuItemError: {
color: theme.palette.error.main,
"&:hover": {
backgroundColor: theme.palette.error.main,
color: theme.palette.error.contrastText,
},
},
})
);
export interface IMenuContentsProps {
menuItems: {
type?: string;
label?: string;
activeLabel?: string;
icon?: JSX.Element;
activeIcon?: JSX.Element;
onClick?: () => void;
active?: boolean;
color?: "error";
}[];
}
export default function MenuContents({ menuItems }: IMenuContentsProps) {
const classes = useStyles();
return (
<>
{menuItems.map((item, index) => {
if (item.type === "subheader")
return (
<>
{index !== 0 && <Divider />}
<ListSubheader
key={index}
className={classes.subheader}
disableGutters
disableSticky
>
{item.label}
</ListSubheader>
</>
);
let icon: JSX.Element = item.icon ?? <></>;
if (item.active && !!item.activeIcon) icon = item.activeIcon;
return (
<>
{index !== 0 && <Divider />}
<MenuItem
key={index}
onClick={item.onClick}
className={clsx(
classes.menuItem,
item.active && classes.menuItemActive,
item.color === "error" && classes.menuItemError
)}
>
<ListItemIcon className={classes.menuItemIcon}>
{icon}
</ListItemIcon>
{item.active ? item.activeLabel : item.label}
</MenuItem>
</>
);
})}
</>
);
}

View File

@@ -0,0 +1,84 @@
import React, { useState } from "react";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Dialog from "@material-ui/core/Dialog";
import Grid from "@material-ui/core/Grid";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import { Typography, IconButton } from "@material-ui/core";
import CloseIcon from "@material-ui/icons/Close";
export default function FormDialog({
name,
fieldName,
open,
handleClose,
handleSave,
}: {
name: string;
fieldName: string;
open: boolean;
handleClose: Function;
handleSave: Function;
}) {
const [newName, setName] = useState(name);
return (
<div>
<Dialog
open={open}
onClose={(e, r) => {
handleClose();
}}
aria-labelledby="form-dialog-title"
>
<DialogContent>
<Grid
container
justify="space-between"
alignContent="flex-start"
direction="row"
>
<Typography variant="h6">Rename Column</Typography>
<IconButton
onClick={() => {
handleClose();
}}
>
<CloseIcon />
</IconButton>
</Grid>
<Typography variant="overline">{name}</Typography>
<TextField
value={newName}
autoFocus
variant="filled"
id="name"
label="Column Header"
type="text"
fullWidth
onChange={e => {
setName(e.target.value);
}}
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
handleClose();
}}
color="primary"
>
Cancel
</Button>
<Button
onClick={() => {
handleSave(fieldName, { name: newName });
}}
color="primary"
>
Update
</Button>
</DialogActions>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import React, { useState } from "react";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Dialog from "@material-ui/core/Dialog";
import Grid from "@material-ui/core/Grid";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import { Typography, IconButton } from "@material-ui/core";
import CloseIcon from "@material-ui/icons/Close";
import { FieldType } from "constants/fields";
import FieldsDropdown from "./FieldsDropdown";
import _camel from "lodash/camelCase";
export default function FormDialog({
open,
data,
handleClose,
handleSave,
}: {
open: boolean;
data: any;
handleClose: Function;
handleSave: Function;
}) {
const [type, setType] = useState(FieldType.shortText);
const [name, setName] = useState("");
return (
<div>
<Dialog
open={open}
onClose={(e, r) => {
handleClose();
}}
aria-labelledby="new-column"
>
<DialogContent>
<Grid
container
justify="space-between"
alignContent="flex-start"
direction="row"
>
<Typography variant="h6">Add new column</Typography>
<IconButton
onClick={() => {
handleClose();
}}
>
<CloseIcon />
</IconButton>
</Grid>
<Typography variant="overline">Column Name</Typography>
<TextField
value={name}
autoFocus
variant="filled"
id="name"
label="Column Header"
type="text"
fullWidth
onChange={e => {
setName(e.target.value);
}}
/>
<Typography variant="overline">Column Type</Typography>
<FieldsDropdown
value={type}
onChange={(newType: any) => {
setType(newType.target.value);
}}
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
handleClose();
}}
color="primary"
>
Cancel
</Button>
<Button
onClick={() => {
const fieldName = _camel(name);
handleSave(fieldName, {
type,
name,
fieldName,
key: fieldName,
config: {},
...data.initializeColumn,
});
}}
color="primary"
>
Add
</Button>
</DialogActions>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,208 @@
import React, { useState } from "react";
import Button from "@material-ui/core/Button";
import Dialog from "@material-ui/core/Dialog";
import Grid from "@material-ui/core/Grid";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import { Typography, IconButton, TextField, Switch } from "@material-ui/core";
import CloseIcon from "@material-ui/icons/Close";
import { FieldType } from "constants/fields";
import OptionsInput from "./ConfigFields/OptionsInput";
import { useFiretableContext } from "contexts/firetableContext";
import MultiSelect from "@antlerengineering/multiselect";
import _sortBy from "lodash/sortBy";
import CodeEditor from "./ConfigFields/CodeEditor";
import FieldsDropdown from "./FieldsDropdown";
import ColumnSelector from "./ConfigFields/ColumnSelector";
const ConfigForm = ({ type, config, handleChange }) => {
const { tableState, tables } = useFiretableContext();
if (!tableState) return <></>;
const { columns } = tableState;
switch (type) {
case FieldType.singleSelect:
case FieldType.multiSelect:
return (
<>
<OptionsInput
options={config.options ?? []}
handleChange={handleChange("options")}
/>
<Typography variant="overline">ADD NEW?</Typography>
<Grid container direction="row" justify="space-between">
<Typography variant="subtitle1">
User can add new options.
</Typography>
<Switch
checked={config.freeText}
onClick={() => {
handleChange("freeText")(!config.freeText);
}}
/>
</Grid>
</>
);
case FieldType.connectTable:
const tableOptions = _sortBy(
tables?.map(t => ({
label: `${t.section} - ${t.name}`,
value: t.collection,
})) ?? [],
"label"
);
return (
<>
<MultiSelect
options={tableOptions}
freeText={false}
value={config.index}
onChange={handleChange("index")}
multiple={false}
/>
<ColumnSelector
label={"Primary Keys"}
value={config.primaryKeys}
table={config.index}
handleChange={handleChange("primaryKeys")}
validTypes={[FieldType.shortText, FieldType.singleSelect]}
/>
<TextField
label="filter template"
name="filters"
fullWidth
onChange={e => {
handleChange("filters")(e.target.value);
}}
/>
</>
);
case FieldType.subTable:
return (
<ColumnSelector
label={"Parent Label"}
value={config.parentLabel}
tableColumns={
columns
? Array.isArray(columns)
? columns
: Object.values(columns)
: []
}
handleChange={handleChange("parentLabel")}
validTypes={[FieldType.shortText, FieldType.singleSelect]}
/>
);
case FieldType.derivative:
return (
<>
<ColumnSelector
label={"Listener fields"}
value={config.listenerFields}
tableColumns={
columns
? Array.isArray(columns)
? columns
: Object.values(columns)
: []
}
handleChange={handleChange("listenerFields")}
/>
<CodeEditor
script={config.script}
handleChange={handleChange("script")}
/>
<FieldsDropdown
value={config.renderFieldType}
onChange={(newType: any) => {
handleChange("renderFieldType")(newType.target.value);
}}
/>
</>
);
default:
return <></>;
}
};
export default function FormDialog({
name,
fieldName,
type,
open,
config,
handleClose,
handleSave,
}: {
name: string;
fieldName: string;
type: FieldType;
open: boolean;
config: any;
handleClose: Function;
handleSave: Function;
}) {
const [newConfig, setNewConfig] = useState(config ?? {});
return (
<div>
<Dialog
open={open}
onClose={(e, r) => {
handleClose();
}}
aria-labelledby="form-column-settings"
>
<DialogContent>
<Grid
container
justify="space-between"
alignContent="flex-start"
direction="row"
>
<Typography variant="h6">{name}: Settings</Typography>
<IconButton
onClick={() => {
handleClose();
}}
>
<CloseIcon />
</IconButton>
</Grid>
<Typography variant="overline"></Typography>
{
<ConfigForm
type={type}
handleChange={key => update => {
console.log(key, update);
setNewConfig({ ...newConfig, [key]: update });
}}
config={newConfig}
/>
}
</DialogContent>
<DialogActions>
<Button
onClick={() => {
handleClose();
}}
color="primary"
>
Cancel
</Button>
<Button
onClick={() => {
handleSave(fieldName, { config: newConfig });
}}
color="primary"
>
Update
</Button>
</DialogActions>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import React, { useState } from "react";
import Button from "@material-ui/core/Button";
import Dialog from "@material-ui/core/Dialog";
import Grid from "@material-ui/core/Grid";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import { Typography, IconButton } from "@material-ui/core";
import CloseIcon from "@material-ui/icons/Close";
import { FieldType } from "constants/fields";
import FieldsDropdown from "./FieldsDropdown";
export default function FormDialog({
name,
fieldName,
type,
open,
handleClose,
handleSave,
}: {
name: string;
fieldName: string;
type: FieldType;
open: boolean;
handleClose: Function;
handleSave: Function;
}) {
const [newType, setType] = useState(type);
return (
<div>
<Dialog
open={open}
onClose={(e, r) => {
handleClose();
}}
aria-labelledby="form-dialog-title"
>
<DialogContent>
<Grid
container
justify="space-between"
alignContent="flex-start"
direction="row"
>
<Typography variant="h6">Change Column Type</Typography>
<IconButton
onClick={() => {
handleClose();
}}
>
<CloseIcon />
</IconButton>
</Grid>
<Typography variant="overline">Current Column: {name}</Typography>
<FieldsDropdown
value={newType}
onChange={(newType: any) => {
setType(newType.target.value);
}}
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
handleClose();
}}
color="primary"
>
Cancel
</Button>
<Button
onClick={() => {
handleSave(fieldName, { type: newType });
}}
color="primary"
>
Update
</Button>
</DialogActions>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,311 @@
import React, { useState, useEffect } from "react";
import { createStyles, makeStyles, Menu } from "@material-ui/core";
import LockOpenIcon from "@material-ui/icons/LockOpen";
import LockIcon from "@material-ui/icons/Lock";
import VisibilityOffIcon from "@material-ui/icons/VisibilityOff";
import VisibilityIcon from "@material-ui/icons/Visibility";
import FreezeIcon from "assets/icons/Freeze";
import UnfreezeIcon from "assets/icons/Unfreeze";
import CellResizeIcon from "assets/icons/CellResize";
import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
import EditIcon from "@material-ui/icons/Edit";
import ReorderIcon from "@material-ui/icons/Reorder";
import SettingsIcon from "@material-ui/icons/Settings";
import ColumnPlusBeforeIcon from "assets/icons/ColumnPlusBefore";
import ColumnPlusAfterIcon from "assets/icons/ColumnPlusAfter";
import ColumnRemoveIcon from "assets/icons/ColumnRemove";
import MenuContents from "./MenuContents";
import NameChange from "./NameChange";
import NewColumn from "./NewColumn";
import TypeChange from "./TypeChange";
import Settings from "./Settings";
import { useFiretableContext } from "contexts/firetableContext";
import { FIELDS, FieldType } from "constants/fields";
import _find from "lodash/find";
import { Column } from "react-data-grid";
import { PopoverProps } from "@material-ui/core";
const INITIAL_MODAL = { type: "", data: {} };
enum ModalStates {
nameChange = "NAME_CHANGE",
typeChange = "TYPE_CHANGE",
new = "NEW_COLUMN",
settings = "COLUMN_SETTINGS",
}
type SelectedColumnHeader = {
column: Column<any> & { [key: string]: any };
anchorEl: PopoverProps["anchorEl"];
};
export type ColumnMenuRef = {
selectedColumnHeader: SelectedColumnHeader | null;
setSelectedColumnHeader: React.Dispatch<
React.SetStateAction<SelectedColumnHeader | null>
>;
};
const useStyles = makeStyles(theme =>
createStyles({
paper: {
// TODO: change this if we need to support a dark mode
backgroundColor: "#f1f1f3",
},
})
);
export default function ColumnMenu() {
const classes = useStyles();
const [modal, setModal] = useState(INITIAL_MODAL);
const { tableState, tableActions, columnMenuRef } = useFiretableContext();
const [selectedColumnHeader, setSelectedColumnHeader] = useState<any>(null);
if (columnMenuRef)
columnMenuRef.current = {
selectedColumnHeader,
setSelectedColumnHeader,
} as any;
const { column, anchorEl } = (selectedColumnHeader ?? {}) as any;
useEffect(() => {
if (column && column.type === FieldType.last) {
setModal({
type: ModalStates.new,
data: {
initializeColumn: { index: column.index ? column.index + 1 : 0 },
},
});
}
}, [column]);
if (!tableState || !tableActions) return null;
const { orderBy } = tableState;
const actions = tableActions!.column;
const handleClose = () => {
if (!setSelectedColumnHeader) return;
setSelectedColumnHeader({
column: column!,
anchorEl: null,
});
setTimeout(() => setSelectedColumnHeader(null), 300);
};
if (!column) return null;
const isSorted = orderBy?.[0]?.key === (column.key as string);
const isAsc = isSorted && orderBy?.[0]?.direction === "asc";
const menuItems = [
{
type: "subheader",
label: column.name,
},
{
label: "Lock",
activeLabel: "Locked (unlock)",
icon: <LockOpenIcon />,
activeIcon: <LockIcon />,
onClick: () => {
actions.update(column.key, { editable: !column.editable });
handleClose();
},
active: !column.editable,
},
{
label: "Hide",
activeLabel: "Show",
icon: <VisibilityOffIcon />,
activeIcon: <VisibilityIcon />,
onClick: () => {
actions.update(column.key, { hidden: !column.hidden });
handleClose();
},
active: column.hidden,
},
{
label: "Freeze",
activeLabel: "Unfreeze",
icon: <FreezeIcon />,
activeIcon: <UnfreezeIcon />,
onClick: () => {
actions.update(column.key, { fixed: !column.fixed });
handleClose();
},
active: column.fixed,
},
{
label: "Enable resize",
activeLabel: "Disable resize",
icon: <CellResizeIcon />,
onClick: () => {
actions.update(column.key, { resizable: !column.resizable });
handleClose();
},
active: column.resizable,
},
{
label: "Sort: Decreasing",
activeLabel: "Sorted: Decreasing",
icon: <ArrowDownwardIcon />,
onClick: () => {
tableActions.table.orderBy(
isSorted && !isAsc ? [] : [{ key: column.key, direction: "desc" }]
);
handleClose();
},
active: isSorted && !isAsc,
},
{
label: "Sort: Increasing",
activeLabel: "Sorted: Increasing",
icon: <ArrowUpwardIcon />,
onClick: () => {
tableActions.table.orderBy(
isSorted && isAsc ? [] : [{ key: column.key, direction: "asc" }]
);
handleClose();
},
active: isSorted && isAsc,
},
{ type: "subheader", label: "Edit" },
{
label: "Rename",
icon: <EditIcon />,
onClick: () => {
setModal({ type: ModalStates.nameChange, data: {} });
},
},
{
label: `Edit Type: ${column?.type}`,
// TODO: This is based off the cell type
icon: _find(FIELDS, { type: column.type })?.icon,
onClick: () => {
setModal({ type: ModalStates.typeChange, data: { column } });
},
},
{
label: `Column Settings`,
// TODO: This is based off the cell type
icon: <SettingsIcon />,
onClick: () => {
setModal({ type: ModalStates.settings, data: { column } });
},
},
// {
// label: "Re-order",
// icon: <ReorderIcon />,
// onClick: () => alert("REORDER"),
// },
{
label: "Add New to Left",
icon: <ColumnPlusBeforeIcon />,
onClick: () =>
setModal({
type: ModalStates.new,
data: {
initializeColumn: { index: column.index ? column.index - 1 : 0 },
},
}),
},
{
label: "Add New to Right",
icon: <ColumnPlusAfterIcon />,
onClick: () =>
setModal({
type: ModalStates.new,
data: {
initializeColumn: { index: column.index ? column.index + 1 : 0 },
},
}),
},
{
label: "Delete Column",
icon: <ColumnRemoveIcon />,
onClick: () => {
actions.remove(column.key);
handleClose();
},
color: "error" as "error",
},
];
const clearModal = () => {
setModal(INITIAL_MODAL);
};
return (
<>
{column.type !== FieldType.last && (
<Menu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
getContentAnchorEl={null}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
classes={{ paper: classes.paper }}
MenuListProps={{ disablePadding: true }}
>
<MenuContents menuItems={menuItems} />
</Menu>
)}
{column && (
<>
<NameChange
name={column.name}
fieldName={column.key as string}
open={modal.type === ModalStates.nameChange}
handleClose={clearModal}
handleSave={(key, update) => {
actions.update(key, update);
clearModal();
handleClose();
}}
/>
<TypeChange
name={column.name}
fieldName={column.key as string}
open={modal.type === ModalStates.typeChange}
type={column.type}
handleClose={clearModal}
handleSave={(key, update) => {
actions.update(key, update);
clearModal();
handleClose();
}}
/>
<Settings
config={column.config}
name={column.name}
fieldName={column.key as string}
open={modal.type === ModalStates.settings}
type={column.type}
handleClose={clearModal}
handleSave={(key, update) => {
actions.update(key, update);
clearModal();
handleClose();
}}
/>
<NewColumn
open={modal.type === ModalStates.new}
data={modal.data}
handleClose={clearModal}
handleSave={(key, update) => {
actions.update(key, update);
clearModal();
handleClose();
}}
/>
</>
)}
</>
);
}

View File

@@ -21,11 +21,7 @@ import { SnackContext } from "contexts/snackContext";
import { useFiretableContext } from "contexts/firetableContext";
import { db } from "../../firebase";
import { FieldType } from "constants/fields";
async function asyncForEach(array, callback) {
for (let index = 0; index < array.length; index++) {
await callback(array[index], index, array);
}
}
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
@@ -223,7 +219,10 @@ export default function ExportCSV() {
MenuProps={MenuProps}
>
{tableState?.columns &&
tableState?.columns.map((column: any) => (
(Array.isArray(tableState?.columns)
? tableState?.columns
: Object.values(tableState?.columns)
).map((column: any) => (
<MenuItem key={column.key} value={column}>
{column.name}
</MenuItem>

View File

@@ -210,8 +210,9 @@ const Filters = ({ columns, setFilters }: any) => {
return (
<MultiSelect
multiple
freeText={false}
onChange={value => setQuery(query => ({ ...query, value }))}
options={selectedColumn.options}
options={selectedColumn.config.options ?? []}
label=""
value={Array.isArray(query?.value) ? query.value : []}
TextFieldProps={{ hiddenLabel: true }}
@@ -221,10 +222,11 @@ const Filters = ({ columns, setFilters }: any) => {
return (
<MultiSelect
multiple={false}
freeText={false}
onChange={value => {
if (value !== null) setQuery(query => ({ ...query, value }));
}}
options={selectedColumn.options}
options={selectedColumn.config.options ?? []}
label=""
value={typeof query?.value === "string" ? query.value : null}
TextFieldProps={{ hiddenLabel: true }}
@@ -237,7 +239,7 @@ const Filters = ({ columns, setFilters }: any) => {
multiple
onChange={value => setQuery(query => ({ ...query, value }))}
value={query.value as string[]}
options={selectedColumn.options}
options={selectedColumn.config.options}
label={""}
searchable={false}
freeText={true}
@@ -401,7 +403,7 @@ const Filters = ({ columns, setFilters }: any) => {
>
{/* <Button color="primary">+ ADD FILTER</Button> */}
<Button
disabled={query.key == ""}
disabled={query.key === ""}
onClick={() => {
setFilters([]);
setQuery({

View File

@@ -39,12 +39,16 @@ const useStyles = makeStyles(theme =>
const FinalColumnHeader: Column<any>["headerRenderer"] = ({ column }) => {
const classes = useStyles();
const { setSelectedColumnHeader } = useFiretableContext();
if (!setSelectedColumnHeader) return null;
const { columnMenuRef } = useFiretableContext();
if (!columnMenuRef) return null;
const handleClick = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => setSelectedColumnHeader({ column, anchorEl: event.currentTarget });
) =>
columnMenuRef?.current?.setSelectedColumnHeader({
column,
anchorEl: event.currentTarget,
});
return (
<Grid

View File

@@ -192,9 +192,11 @@ export default function ImportCSV(props: any) {
id: "column-keys",
}}
>
{tableState?.columns.map((column: any) => (
<MenuItem value={column.key}>{column.name}</MenuItem>
))}
{Object.values(tableState?.columns as any).map(
(column: any) => (
<MenuItem value={column.key}>{column.name}</MenuItem>
)
)}
</Select>
</FormControl>
<IconButton

View File

@@ -0,0 +1,101 @@
import React from "react";
import { FieldType } from "constants/fields";
import { Grid, Button, Tooltip } from "@material-ui/core";
import { useFiretableContext } from "contexts/firetableContext";
import { db } from "../../firebase";
import AlertIcon from "@material-ui/icons/Warning";
const MigrateButton = ({ columns, needsMigration }) => {
const { tableState } = useFiretableContext();
const configDocPath = tableState?.config.tableConfig.path;
const handleColumnMigration = async () => {
console.log({ columns });
const newColumns = columns.reduce((acc, currCol, currIndex) => {
const baseCol = {
...currCol,
fieldName: currCol.key,
index: currIndex,
};
if (currCol.type === FieldType.connectTable) {
const index = currCol.collectionPath;
delete baseCol.collectionPath;
return {
...acc,
[currCol.key]: { ...baseCol, config: { ...currCol.config, index } },
};
} else if (currCol.type === FieldType.subTable) {
const parentLabel = [currCol.parentLabel];
delete baseCol.parentLabel;
return {
...acc,
[currCol.key]: {
...baseCol,
config: { ...currCol.config, parentLabel },
},
};
} else if (currCol.type === FieldType.action) {
const callableName = currCol.callableName;
delete baseCol.callableName;
return {
...acc,
[currCol.key]: {
...baseCol,
config: { ...currCol.config, callableName },
},
};
} else if (
[FieldType.multiSelect, FieldType.singleSelect].includes(currCol.type)
) {
const options = currCol.options;
delete baseCol.options;
return {
...acc,
[currCol.key]: {
...baseCol,
config: { ...currCol.config, options },
},
};
} else return { ...acc, [currCol.key]: baseCol };
}, {});
await db
.doc(configDocPath)
.update({ columns: newColumns, oldColumns: columns });
window.location.reload();
};
if (needsMigration)
return (
<Grid item>
<Tooltip
title={
"press this to restructure columns to support new column menus"
}
>
<Button
color="secondary"
variant="outlined"
onClick={handleColumnMigration}
>
{" "}
<AlertIcon /> Migrate columns <AlertIcon />
</Button>
</Tooltip>
</Grid>
);
else return <></>;
// else
// return (
// <Button
// onClick={async () => {
// const tableConfigDoc = await db.doc(configDocPath).get();
// const tableConfig = tableConfigDoc.data();
// await db
// .doc(configDocPath)
// .update({ columns: tableConfig?.oldColumns });
// }}
// >
// Undo
// </Button>
// );
};
export default MigrateButton;

View File

@@ -8,6 +8,7 @@ import {
MenuItem,
Typography,
Button,
Tooltip,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
@@ -19,6 +20,7 @@ import { FireTableFilter } from "hooks/useFiretable";
import { DRAWER_COLLAPSED_WIDTH } from "components/SideDrawer";
import { useFiretableContext } from "contexts/firetableContext";
import { FieldType } from "constants/fields";
import MigrateButton from "./MigrateButton";
export const TABLE_HEADER_HEIGHT = 56;
@@ -55,11 +57,8 @@ const useStyles = makeStyles(theme =>
);
interface ITableHeaderProps {
collection: string;
rowHeight: number;
updateConfig: Function;
columns: any;
filters: FireTableFilter[];
}
@@ -67,14 +66,18 @@ interface ITableHeaderProps {
* TODO: Make this properly mobile responsive, not just horizontally scrolling
*/
export default function TableHeader({
collection,
rowHeight,
updateConfig,
columns,
filters,
}: ITableHeaderProps) {
const classes = useStyles();
const { tableActions, userClaims } = useFiretableContext();
const { tableActions, tableState } = useFiretableContext();
if (!tableState || !tableState.columns) return <></>;
const { columns } = tableState;
const needsMigration = Array.isArray(columns) && columns.length !== 0;
const tempColumns = needsMigration ? columns : Object.values(columns);
return (
<Grid
@@ -84,10 +87,11 @@ export default function TableHeader({
wrap="nowrap"
className={classes.root}
>
<MigrateButton needsMigration={needsMigration} columns={tempColumns} />
<Grid item>
<Button
onClick={() => {
const initialVal = columns.reduce((acc, currCol) => {
const initialVal = tempColumns.reduce((acc, currCol) => {
if (currCol.type === FieldType.checkbox) {
return { ...acc, [currCol.key]: false };
} else {
@@ -108,7 +112,7 @@ export default function TableHeader({
<Grid item>
<Filters
columns={columns}
columns={tempColumns}
tableFilters={filters}
setFilters={tableActions?.table.filter}
/>
@@ -160,11 +164,10 @@ export default function TableHeader({
<ImportCSV />
</Grid>
{/* )} */}
{userClaims && !userClaims.roles?.includes("READONLY") && (
<Grid item>
<ExportCSV />
</Grid>
)}
<Grid item>
<ExportCSV />
</Grid>
</Grid>
);
}

View File

@@ -12,9 +12,12 @@ import TextEditor from "./TextEditor";
* @param column Must have column `type`
*/
export const getEditor = (column: any) => {
const { type } = column;
switch (type) {
const { type, config } = column;
let _type = type;
if (config && config.renderFieldType) {
_type = config.renderFieldType;
}
switch (_type) {
// Can be edited without double-clicking
case FieldType.date:
case FieldType.dateTime:

View File

@@ -41,7 +41,7 @@ export default function Action({
const classes = useStyles();
const { createdAt, updatedAt, id, ref, ...docData } = row;
const { callableName } = column as any;
const { callableName, config } = column as any;
const action = !value
? "run"
: value.undo
@@ -60,9 +60,8 @@ export default function Action({
column,
action,
};
console.log(data);
cloudFunction(
callableName,
callableName ?? config.callableName,
data,
response => {
const { message, cellValue, success } = response.data;
@@ -71,7 +70,7 @@ export default function Action({
if (cellValue) onSubmit(cellValue);
},
error => {
console.error("ERROR", callableName, error);
console.error("ERROR", callableName ?? config.callableName, error);
setIsRunning(false);
snack.open({ message: JSON.stringify(error), severity: "error" });
}
@@ -127,13 +126,13 @@ export default function Action({
>
<Grid item xs className={classes.labelContainer}>
{hasRan && isUrl(value.status) ? (
<a href={value.status} target="_blank" rel="noopener noreferer">
<a href={value.status} target="_blank" rel="noopener noreferrer">
{value.status}
</a>
) : hasRan ? (
value.status
) : (
sanitiseCallableName(callableName)
sanitiseCallableName(callableName ?? config.callableName)
)}
</Grid>

View File

@@ -7,10 +7,10 @@ import {
FormControlLabel,
Switch,
} from "@material-ui/core";
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) },

View File

@@ -60,8 +60,8 @@ export default function ConnectTable({
const { collectionPath, config } = column as any;
const { dataGridRef } = useFiretableContext();
const disabled = !column.editable || config.isLocked;
if (!config || !config.primaryKeys) return <></>;
const disabled = !column.editable || config?.isLocked;
// Render chips
const renderValue = value => (

View File

@@ -57,7 +57,7 @@ export default function MultiSelect({
}: CustomCellProps) {
const classes = useStyles();
const { options } = column as any;
const { config } = column as any;
const { dataGridRef } = useFiretableContext();
// Support SingleSelect field
@@ -99,11 +99,11 @@ export default function MultiSelect({
value === undefined || value === null ? (isSingle ? null : []) : value
}
onChange={onSubmit}
freeText={false}
freeText={!isSingle && config.freeText}
multiple={!isSingle as any}
label={column.name}
labelPlural={column.name}
options={options}
options={config.options ?? []}
disabled={column.editable === false}
onOpen={handleOpen}
TextFieldProps={

View File

@@ -19,7 +19,15 @@ const useStyles = makeStyles(theme =>
export default function SubTable({ column, row }: CustomCellProps) {
const classes = useStyles();
const { parentLabel } = column as any;
const { parentLabel, config } = column as any;
const label = parentLabel
? row[parentLabel]
: config.parentLabel
? config.parentLabel.reduce((acc, curr) => {
if (acc !== "") return `${acc} - ${row[curr]}`;
else return row[curr];
}, "")
: "";
const fieldName = column.key as string;
const router = useRouter();
@@ -31,11 +39,11 @@ export default function SubTable({ column, row }: CustomCellProps) {
if (parentLabels)
subTablePath =
encodeURIComponent(`${row.ref.path}/${fieldName}`) +
`?parentLabel=${parentLabels},${row[parentLabel]}`;
`?parentLabel=${parentLabels},${label}`;
else
subTablePath =
encodeURIComponent(`${row.ref.path}/${fieldName}`) +
`?parentLabel=${encodeURIComponent(row[parentLabel])}`;
`?parentLabel=${encodeURIComponent(label)}`;
return (
<Grid
@@ -46,7 +54,7 @@ export default function SubTable({ column, row }: CustomCellProps) {
className={clsx("cell-collapse-padding", classes.root)}
>
<Grid item xs className={classes.labelContainer}>
{column.name}: {row[parentLabel]}
{column.name}: {label}
</Grid>
<Grid item>

View File

@@ -47,7 +47,11 @@ const Percentage = lazy(() =>
* @param column Must have column `type`
*/
export const getFormatter = (column: any) => {
switch (column.type) {
let _type = column.type;
if (column.config.renderFieldType) {
_type = column.config.renderFieldType;
}
switch (_type) {
case FieldType.date:
case FieldType.dateTime:
return withCustomCell(DatePicker);

View File

@@ -1,4 +1,6 @@
import React, { lazy, Suspense, useEffect, useRef } from "react";
import _orderBy from "lodash/orderBy";
import { useDebouncedCallback } from "use-debounce";
import _isEmpty from "lodash/isEmpty";
@@ -16,6 +18,7 @@ import Loading from "components/Loading";
import SubTableBreadcrumbs, { BREADCRUMBS_HEIGHT } from "./SubTableBreadcrumbs";
import TableHeader, { TABLE_HEADER_HEIGHT } from "./TableHeader";
import ColumnHeader from "./ColumnHeader";
import ColumnMenu from "./ColumnMenu";
import FinalColumnHeader from "./FinalColumnHeader";
import FinalColumn, { useFinalColumnStyles } from "./formatters/FinalColumn";
@@ -27,14 +30,11 @@ import { getFormatter } from "./formatters";
import { getEditor } from "./editors";
import useWindowSize from "hooks/useWindowSize";
import { DRAWER_WIDTH, DRAWER_COLLAPSED_WIDTH } from "components/SideDrawer";
import { DRAWER_COLLAPSED_WIDTH } from "components/SideDrawer";
import { APP_BAR_HEIGHT } from "components/Navigation";
import useStyles from "./styles";
// const Hotkeys = lazy(() => import("./HotKeys" /* webpackChunkName: "HotKeys" */));
const ColumnEditor = lazy(() =>
import("./ColumnEditor" /* webpackChunkName: "ColumnEditor" */)
);
const { DraggableContainer } = DraggableHeader;
export type FiretableColumn = Column<any> & {
@@ -99,24 +99,28 @@ export default function Table({ collection, filters }: ITableProps) {
let columns: FiretableColumn[] = [];
if (!tableState.loadingColumns && tableState.columns) {
columns = tableState.columns
.filter((column: any) => !column.hidden)
.map((column: any, index) => ({
draggable: true,
editable: true,
resizable: true,
frozen: column.fixed,
headerRenderer: ColumnHeader,
formatter: getFormatter(column),
editor: getEditor(column),
...column,
width: column.width ? (column.width > 380 ? 380 : column.width) : 150,
}));
columns = _orderBy(
Object.values(tableState.columns).filter(
(column: any) => !column.hidden && column.key
),
"index"
).map((column: any, index) => ({
draggable: true,
editable: true,
resizable: true,
frozen: column.fixed,
headerRenderer: ColumnHeader,
formatter: getFormatter(column),
editor: getEditor(column),
...column,
width: column.width ? (column.width > 380 ? 380 : column.width) : 150,
}));
columns.push({
isNew: true,
key: "new",
name: "Add column",
type: FieldType.last,
index: columns.length,
width: 160,
headerRenderer: FinalColumnHeader,
cellClass: finalColumnClasses.cell,
@@ -126,7 +130,6 @@ export default function Table({ collection, filters }: ITableProps) {
}
const rowHeight = tableState.config.rowHeight;
const rows = tableState.rows;
const rowGetter = (rowIdx: number) => rows[rowIdx];
@@ -147,10 +150,8 @@ export default function Table({ collection, filters }: ITableProps) {
{inSubTable && <SubTableBreadcrumbs collection={collection} />}
<TableHeader
collection={collection}
rowHeight={rowHeight}
updateConfig={tableActions.table.updateConfig}
columns={columns}
filters={filters}
/>
@@ -214,9 +215,7 @@ export default function Table({ collection, filters }: ITableProps) {
<Loading message="Fetching columns" />
)}
<Suspense fallback={<Loading message="Loading helpers" />}>
<ColumnEditor />
</Suspense>
<ColumnMenu />
</>
);
}

View File

@@ -0,0 +1,170 @@
import React, { useState, useEffect } from "react";
import _camelCase from "lodash/camelCase";
import useRouter from "../hooks/useRouter";
import {
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
Button,
Select,
Typography,
} from "@material-ui/core";
import { useFiretableContext } from "../contexts/firetableContext";
import OptionsInput from "./Table/ColumnMenu/ConfigFields/OptionsInput";
export enum TableSettingsDialogModes {
create,
update,
}
export interface ICreateTableDialogProps {
/** dialog Modes create or udpate table */
mode: TableSettingsDialogModes | null;
clearDialog: () => void;
data: {
collection: string;
roles: string[];
section: string;
description: string;
name: string;
} | null;
}
const FORM_EMPTY_STATE = {
name: "",
section: "",
collection: "",
description: "",
roles: ["ADMIN"],
};
export default function TableSettingsDialog({
mode,
clearDialog,
data,
}: ICreateTableDialogProps) {
const { settingsActions } = useFiretableContext();
const router = useRouter();
const open = mode !== null;
const [formState, setForm] = useState(FORM_EMPTY_STATE);
const handleChange = (key: string, value: any) =>
setForm({ ...formState, [key]: value });
useEffect(() => {
if (mode === TableSettingsDialogModes.create)
handleChange("collection", _camelCase(formState.name));
}, [formState.name]);
function handleClose() {
setForm(FORM_EMPTY_STATE);
clearDialog();
}
function handleCreate() {
settingsActions?.createTable(formState);
if (router.location.pathname === "/") {
router.history.push(`table/${formState.collection}`);
} else {
router.history.push(formState.collection);
}
handleClose();
}
async function handleUpdate() {
await Promise.all([settingsActions?.updateTable(formState), handleClose()]);
window.location.reload();
}
useEffect(() => {
if (data) setForm(data);
}, [data]);
return (
<div>
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="form-dialog-title"
>
<DialogTitle id="form-dialog-title">
{mode === TableSettingsDialogModes.create
? "New table"
: "Update Table"}
</DialogTitle>
<DialogContent>
<DialogContentText>
{mode === TableSettingsDialogModes.create
? "Create a new Table"
: `Updating table with collection key:${formState.collection}`}
</DialogContentText>
<TextField
autoFocus
onChange={e => handleChange("name", e.target.value)}
value={formState.name}
margin="dense"
id="name"
label="Table Name"
type="text"
fullWidth
variant="filled"
/>
{mode === TableSettingsDialogModes.create && (
<TextField
value={formState.collection}
onChange={e => handleChange("collection", e.target.value)}
variant="filled"
margin="dense"
id="collection"
label="Collection Name"
type="text"
fullWidth
/>
)}
<TextField
value={formState.section}
onChange={e => handleChange("section", e.target.value)}
variant="filled"
margin="dense"
id="section"
label="Section Name"
type="text"
fullWidth
/>
<TextField
label="Description"
id="description"
variant="filled"
fullWidth
multiline={true}
value={formState.description}
onChange={e => handleChange("description", e.target.value)}
/>
<OptionsInput
placeholder={"Add Role"}
options={formState.roles ?? []}
handleChange={update => handleChange("roles", update)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
{mode === TableSettingsDialogModes.create && (
<Button onClick={handleCreate} color="primary">
Create
</Button>
)}
{mode === TableSettingsDialogModes.update && (
<Button onClick={handleUpdate} color="primary">
Update
</Button>
)}
</DialogActions>
</Dialog>
</div>
);
}

View File

@@ -34,6 +34,7 @@ import RichTextIcon from "@material-ui/icons/TextFormat";
import ColorIcon from "@material-ui/icons/Colorize";
import SliderIcon from "assets/icons/Slider";
import CodeIcon from "@material-ui/icons/Code";
import DerivativeIcon from "@material-ui/icons/Functions";
import UserIcon from "@material-ui/icons/Person";
export {
@@ -94,6 +95,7 @@ export enum FieldType {
json = "JSON",
user = "USER",
derivative = "DERIVATIVE",
last = "LAST",
}
@@ -147,6 +149,7 @@ export const FIELDS = [
{ icon: <JsonIcon />, name: "JSON", type: FieldType.json },
{ icon: <UserIcon />, name: "User", type: FieldType.user },
{ icon: <CodeIcon />, name: "Code", type: FieldType.code },
{ icon: <DerivativeIcon />, name: "Derivative", type: FieldType.derivative },
];
/**

View File

@@ -1,8 +1,7 @@
import React, { useState, useContext, useEffect, useRef } from "react";
import _groupBy from "lodash/groupBy";
import { Column, DataGridHandle } from "react-data-grid";
import { PopoverProps } from "@material-ui/core";
import _sortBy from "lodash/sortBy";
import { DataGridHandle } from "react-data-grid";
import firebase from "firebase/app";
import useFiretable, {
FiretableActions,
@@ -12,11 +11,7 @@ import useSettings from "hooks/useSettings";
import { useAppContext } from "./appContext";
import { useSnackContext } from "./snackContext";
import { SideDrawerRef } from "components/SideDrawer";
type SelectedColumnHeader = {
column: Column<any> & { [key: string]: any };
anchorEl: PopoverProps["anchorEl"];
};
import { ColumnMenuRef } from "components/Table/ColumnMenu";
export type Table = {
collection: string;
@@ -37,19 +32,32 @@ interface FiretableContextProps {
fieldName: string,
value: any
) => void;
createTable: Function;
userClaims: any;
settingsActions: {
createTable: (data: {
collection: string;
name: string;
description: string;
roles: string[];
section: string;
}) => void;
updateTable: (data: {
collection: string;
name: string;
description: string;
roles: string[];
section: string;
}) => Promise<any>;
deleteTable: (collection: string) => void;
};
// TODO: Investigate if this can be moved out of this context
selectedColumnHeader: SelectedColumnHeader | null;
setSelectedColumnHeader: React.Dispatch<
React.SetStateAction<SelectedColumnHeader | null>
>;
userClaims: any;
// A ref to the data grid. Contains data grid functions
dataGridRef: React.RefObject<DataGridHandle>;
// A ref to the side drawer state. Prevents unnecessary re-renders
sideDrawerRef: React.MutableRefObject<SideDrawerRef | undefined>;
// A ref to the column menu. Prevents unnecessary re-renders
columnMenuRef: React.MutableRefObject<ColumnMenuRef | undefined>;
}
const firetableContext = React.createContext<Partial<FiretableContextProps>>(
@@ -83,36 +91,35 @@ export const FiretableContextProvider: React.FC = ({ children }) => {
const { tableState, tableActions } = useFiretable();
const [tables, setTables] = useState<FiretableContextProps["tables"]>();
const [sections, setSections] = useState<FiretableContextProps["sections"]>();
const [settings, createTable] = useSettings();
const [settings, settingsActions] = useSettings();
const [userRoles, setUserRoles] = useState<null | string[]>();
const [userClaims, setUserClaims] = useState<any>();
const [
selectedColumnHeader,
setSelectedColumnHeader,
] = useState<SelectedColumnHeader | null>(null);
const { currentUser } = useAppContext();
useEffect(() => {
const { tables } = settings;
if (tables && userRoles && !sections) {
const filteredTables = tables.filter(
table =>
!table.roles || table.roles.some(role => userRoles.includes(role))
);
const filteredTables = _sortBy(tables, "name")
.filter(
table =>
!table.roles || table.roles.some(role => userRoles.includes(role))
)
.map(table => ({
...table,
section: table.section ? table.section.toUpperCase().trim() : "OTHER",
}));
const sections = _groupBy(filteredTables, "section");
setSections(sections);
const _sections = _groupBy(filteredTables, "section");
setSections(_sections);
setTables(filteredTables);
}
}, [settings, userRoles]);
}, [settings, userRoles, sections]);
useEffect(() => {
if (currentUser && !userClaims) {
currentUser.getIdTokenResult(true).then(results => {
setUserRoles(results.claims.roles || []);
setUserClaims(results.claims);
// setUserRegions(results.claims.regions || []);
});
}
}, [currentUser]);
@@ -145,7 +152,7 @@ export const FiretableContextProvider: React.FC = ({ children }) => {
open({
message: `You don't have permissions to make this change`,
severity: "error",
duration: 3000,
duration: 2000,
position: { horizontal: "center", vertical: "top" },
});
}
@@ -156,6 +163,7 @@ export const FiretableContextProvider: React.FC = ({ children }) => {
// A ref to the data grid. Contains data grid functions
const dataGridRef = useRef<DataGridHandle>(null);
const sideDrawerRef = useRef<SideDrawerRef>();
const columnMenuRef = useRef<ColumnMenuRef>();
return (
<firetableContext.Provider
@@ -163,14 +171,13 @@ export const FiretableContextProvider: React.FC = ({ children }) => {
tableState,
tableActions,
updateCell,
createTable,
settingsActions,
tables,
sections,
userClaims,
selectedColumnHeader,
setSelectedColumnHeader,
dataGridRef,
sideDrawerRef,
columnMenuRef,
}}
>
{children}

View File

@@ -22,8 +22,12 @@ db.enablePersistence({ synchronizeTabs: true });
export const bucket = firebase.storage();
export const functions = firebase.functions();
export const googleProvider = new firebase.auth.GoogleAuthProvider().setCustomParameters(
{
prompt: "select_account",
}
);
export const deleteField = firebase.firestore.FieldValue.delete;

View File

@@ -13,13 +13,16 @@ const documentIntialState = {
ref: null,
loading: true,
};
const documentReducer = (prevState: any, newProps: any) => {
switch (newProps.action) {
case DocActions.clear:
return documentIntialState;
case DocActions.update:
// takes data object form the dispatcher and updates doc
prevState.ref.set({ ...newProps.data }, { merge: true });
return { ...prevState, doc: { ...prevState.doc, ...newProps.data } };
case DocActions.delete:
prevState.ref.delete();

View File

@@ -23,7 +23,7 @@ export type FiretableActions = {
export type FiretableState = {
orderBy: FiretableOrderBy;
tablePath: string;
config: { rowHeight: number };
config: { rowHeight: number; tableConfig: any };
columns: any[];
rows: { [key: string]: any }[];
queryLimit: number;
@@ -72,7 +72,7 @@ const useFiretable = (
tablePath: tableState.path,
filters: tableState.filters,
columns: tableConfig.columns,
config: { rowHeight: tableConfig.rowHeight },
config: { rowHeight: tableConfig.rowHeight, tableConfig },
rows: tableState.rows,
queryLimit: tableState.limit,
loadingRows: tableState.loading,

View File

@@ -106,7 +106,6 @@ const useTable = (initialOverrides: any) => {
const ref = doc.ref;
return { ...data, id, ref };
});
console.log(tableState.path, { orderBy, rows });
tableDispatch({
rows,
loading: false,
@@ -221,6 +220,7 @@ const useTable = (initialOverrides: any) => {
if (tableCollection !== tableState.path) {
tableDispatch({
path: tableCollection,
rows: [],
orderBy: [],
});
}

View File

@@ -4,8 +4,12 @@ import useDoc, { DocActions } from "../useDoc";
import { FieldType } from "constants/fields";
import _camelCase from "lodash/camelCase";
import _findIndex from "lodash/findIndex";
import _find from "lodash/find";
import _sortBy from "lodash/sortBy";
import { arrayMover } from "../../util/fns";
import { db } from "../../firebase";
import { db, deleteField } from "../../firebase";
//import
const formatPathRegex = /\/[^\/]+\/([^\/]+)/g;
@@ -45,13 +49,14 @@ const useTableConfig = (tablePath?: string) => {
*/
const add = (name: string, type: FieldType, data?: any) => {
//TODO: validation
//console.log("tableConfigState", tableConfigState);
const { columns } = tableConfigState;
const newIndex = Object.keys(columns).length;
let updatedColumns = columns;
const key = _camelCase(name);
updatedColumns[key] = { name, key, type, ...data, index: newIndex };
documentDispatch({
action: DocActions.update,
data: { columns: [...columns, { name, key, type, ...data }] },
data: { columns: updatedColumns },
});
};
@@ -61,8 +66,21 @@ const useTableConfig = (tablePath?: string) => {
*/
const [resize] = useDebouncedCallback((index: number, width: number) => {
const { columns } = tableConfigState;
columns[index].width = width;
documentDispatch({ action: DocActions.update, data: { columns } });
const numberOfFixedColumns = Object.values(columns).filter(
(col: any) => col.fixed && !col.hidden
).length;
const columnsArray = _sortBy(
Object.values(columns).filter((col: any) => !col.hidden && !col.fixed),
"index"
);
let column: any = columnsArray[index - numberOfFixedColumns];
column.width = width;
let updatedColumns = columns;
updatedColumns[column.key] = column;
documentDispatch({
action: DocActions.update,
data: { columns: updatedColumns },
});
}, 1000);
type updatable = { field: string; value: unknown };
@@ -70,20 +88,30 @@ const useTableConfig = (tablePath?: string) => {
* @param index of column.
* @param {updatable[]} updatables properties to be updated
*/
const updateColumn = (index: number, updatables: updatable[]) => {
const updateColumn = (key: string, updates: any) => {
const { columns } = tableConfigState;
updatables.forEach((updatable: updatable) => {
columns[index][updatable.field] = updatable.value;
const updatedColumns = {
...columns,
[key]: { ...columns[key], ...updates },
};
documentDispatch({
action: DocActions.update,
data: { columns: updatedColumns },
});
documentDispatch({ action: DocActions.update, data: { columns } });
};
/** remove column by index
* @param index of column.
*/
const remove = (index: number) => {
const remove = (key: string) => {
const { columns } = tableConfigState;
columns.splice(index, 1);
documentDispatch({ action: DocActions.update, data: { columns } });
let updatedColumns = columns;
updatedColumns[key] = deleteField();
documentDispatch({
action: DocActions.update,
data: { columns: updatedColumns },
});
};
/** reorder columns by key
* @param draggedColumnKey column being repositioned.
@@ -91,13 +119,18 @@ const useTableConfig = (tablePath?: string) => {
*/
const reorder = (draggedColumnKey: string, droppedColumnKey: string) => {
const { columns } = tableConfigState;
const draggedColumnIndex = _findIndex(columns, ["key", draggedColumnKey]);
const droppedColumnIndex = _findIndex(columns, ["key", droppedColumnKey]);
const reorderedColumns = [...columns];
arrayMover(reorderedColumns, draggedColumnIndex, droppedColumnIndex);
console.log(columns[draggedColumnKey], columns[droppedColumnKey]);
const oldIndex = columns[draggedColumnKey].index;
const newIndex = columns[droppedColumnKey].index;
const columnsArray = _sortBy(Object.values(columns), "index");
arrayMover(columnsArray, oldIndex, newIndex);
let updatedColumns = columns;
columnsArray.forEach((column: any, index) => {
updatedColumns[column.key] = { ...column, index };
});
documentDispatch({
action: DocActions.update,
data: { columns: reorderedColumns },
data: { columns: updatedColumns },
});
};
/** changing table configuration used for things such as row height

View File

@@ -2,6 +2,7 @@ import { useEffect } from "react";
import useDoc, { DocActions } from "./useDoc";
import { db } from "../firebase";
import _groupBy from "lodash/groupBy";
import _find from "lodash/find";
const useSettings = () => {
const [settingsState, documentDispatch] = useDoc({
@@ -22,26 +23,65 @@ const useSettings = () => {
}
}, [settingsState]);
const createTable = (name: string, collection: string) => {
const createTable = (data: {
name: string;
collection: string;
description: string;
roles: string[];
}) => {
const { tables } = settingsState;
// updates the setting doc
if (tables) {
documentDispatch({
action: DocActions.update,
data: { tables: [...tables, { name, collection }] },
});
} else {
db.doc("_FIRETABLE_/settings").set(
{ tables: [{ name, collection }] },
{ merge: true }
);
}
db.doc("_FIRETABLE_/settings").set(
{ tables: tables ? [...tables, data] : [data] },
{ merge: true }
);
//create the firetable collection doc with empty columns
db.collection("_FIRETABLE_/settings/schema")
.doc(collection)
.set({ name, columns: [] });
.doc(data.collection)
.set({ ...data, columns: [] }, { merge: true });
};
return [settingsState, createTable];
const updateTable = (data: {
name: string;
collection: string;
description: string;
roles: string[];
}) => {
const { tables } = settingsState;
const table = tables.filter(t => t.collection === data.collection)[0];
return Promise.all([
db.doc("_FIRETABLE_/settings").set(
{
tables: tables
? [
...tables.filter(table => table.collection !== data.collection),
{ table, ...data },
]
: [data],
},
{ merge: true }
),
//update the firetable collection doc with empty columns
db
.collection("_FIRETABLE_/settings/schema")
.doc(data.collection)
.set({ ...data }, { merge: true }),
]);
};
const deleteTable = (collection: string) => {
const { tables } = settingsState;
db.doc("_FIRETABLE_/settings").update({
tables: tables.filter(table => table.collection !== collection),
});
db.collection("_FIRETABLE_/settings/schema")
.doc(collection)
.delete();
};
const settingsActions = { createTable, updateTable, deleteTable };
return [settingsState, settingsActions];
};
export default useSettings;

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import _groupBy from "lodash/groupBy";
import _find from "lodash/find";
import {
@@ -9,8 +9,13 @@ import {
Link,
Typography,
Divider,
Fab,
Checkbox,
Tooltip,
IconButton,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import EditIcon from "@material-ui/icons/Edit";
import Favorite from "@material-ui/icons/Favorite";
import FavoriteBorder from "@material-ui/icons/FavoriteBorder";
import SecurityIcon from "@material-ui/icons/Security";
@@ -18,11 +23,14 @@ import AppBar from "components/AppBar";
import Loading from "components/Loading";
import EmptyState from "components/EmptyState";
import StyledCard from "components/StyledCard";
import CreateTableDialog from "components/CreateTableDialog";
import routes from "constants/routes";
import { useFiretableContext } from "contexts/firetableContext";
import { useAppContext } from "contexts/appContext";
import { DocActions } from "hooks/useDoc";
import TableSettingsDialog, {
TableSettingsDialogModes,
} from "components/TableSettingsDialog";
const useStyles = makeStyles(theme =>
createStyles({
root: { minHeight: "100vh", paddingBottom: theme.spacing(8) },
@@ -55,30 +63,50 @@ const useStyles = makeStyles(theme =>
[theme.breakpoints.down("md")]: { minHeight: 180 },
},
createTableContainer: {
alignSelf: "flex-end",
marginLeft: "auto",
createTable: {
padding: theme.spacing(2),
},
createTableFab: {
fab: {
width: 80,
height: 80,
borderRadius: theme.shape.borderRadius * 2,
"& svg": { width: "2em", height: "2em" },
},
})
);
const regionalFilter = (regional, userClaims) =>
regional && userClaims?.regions && !userClaims?.regions?.includes("GLOBAL")
? `?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 [settingsDialogState, setSettingsDialogState] = useState<{
mode: null | TableSettingsDialogModes;
data: null | {
collection: string;
description: string;
roles: string[];
name: string;
section: string;
};
}>({
mode: null,
data: null,
});
const clearDialog = () =>
setSettingsDialogState({
mode: null,
data: null,
});
const classes = useStyles();
const { sections, createTable, userClaims } = useFiretableContext();
const { sections, userClaims } = useFiretableContext();
const { userDoc } = useAppContext();
const favs = userDoc.state.doc?.favoriteTables
? userDoc.state.doc.favoriteTables
: [];
const TableCard = props => {
const { table } = props;
const TableCard = ({ table }) => {
const checked = Boolean(_find(favs, table));
return (
<Grid key={table.name} item xs={12} sm={6} md={4}>
@@ -86,18 +114,7 @@ const TablesView = () => {
className={classes.card}
overline={table.section}
title={table.name}
bodyContent={table.description}
primaryLink={{
to: `${routes.table}/${table.collection}${
table.regional &&
userClaims?.regions &&
!userClaims?.regions.includes("GLOBAL")
? `?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`
: ""
}`,
label: "Open",
}}
secondaryAction={
headerAction={
<Checkbox
onClick={e => {
userDoc.dispatch({
@@ -115,59 +132,61 @@ const TablesView = () => {
name="checkedH"
/>
}
bodyContent={table.description}
primaryLink={{
to: `${routes.table}/${table.collection}${regionalFilter(
table?.regional ?? false,
userClaims
)}`,
label: "Open",
}}
secondaryAction={
<IconButton
onClick={() =>
setSettingsDialogState({
mode: TableSettingsDialogModes.update,
data: table,
})
}
>
<EditIcon />
</IconButton>
}
/>
</Grid>
);
};
return (
<main className={classes.root}>
<AppBar />
<Container>
{(!userClaims?.roles || userClaims.roles.length === 0) && (
<EmptyState
Icon={SecurityIcon}
message={"You don't have any permissions specified"}
description={
<>
Please contact the Assistant <em>to</em> the Regional Manager of
your branch then{" "}
<Link
component="button"
onClick={() => {
window.location.reload();
}}
variant="body2"
style={{ verticalAlign: "baseline" }}
>
refresh this page
</Link>
.
</>
}
/>
)}
{favs.length !== 0 && (
<div key={"favorites"} className={classes.section}>
<Typography variant="overline">favorites</Typography>
<Divider className={classes.divider} />
<Grid
container
spacing={4}
justify="flex-start"
className={classes.cardGrid}
>
{favs.map(table => (
<TableCard table={table} />
))}
</Grid>
</div>
)}
{sections ? (
Object.keys(sections).map(sectionName => (
<div key={sectionName} className={classes.section}>
<Typography variant="overline">
{sectionName == "undefined" ? "Other" : sectionName}
</Typography>
<>
<main className={classes.root}>
<AppBar />
<Container>
{(!userClaims?.roles || userClaims.roles.length === 0) && (
<EmptyState
Icon={SecurityIcon}
message={"You don't have any permissions specified"}
description={
<>
Please contact the Assistant <em>to</em> the Regional Manager
of your branch then{" "}
<Link
component="button"
onClick={() => {
window.location.reload();
}}
variant="body2"
style={{ verticalAlign: "baseline" }}
>
refresh this page
</Link>
.
</>
}
/>
)}
{favs.length !== 0 && (
<div key={"favorites"} className={classes.section}>
<Typography variant="overline">favorites</Typography>
<Divider className={classes.divider} />
<Grid
container
@@ -175,28 +194,60 @@ const TablesView = () => {
justify="flex-start"
className={classes.cardGrid}
>
{sections[sectionName].map(table => (
<TableCard table={table} />
{favs.map(table => (
<TableCard key={table.collection} table={table} />
))}
</Grid>
</div>
))
) : (
<Loading />
)}
)}
{sections ? (
Object.keys(sections).map(sectionName => (
<div key={sectionName} className={classes.section}>
<Typography variant="overline">
{sectionName === "undefined" ? "Other" : sectionName}
</Typography>
<Divider className={classes.divider} />
<Grid
container
spacing={4}
justify="flex-start"
className={classes.cardGrid}
>
{sections[sectionName].map(table => (
<TableCard key={table.collection} table={table} />
))}
</Grid>
</div>
))
) : (
<Loading />
)}
<div className={classes.createTable}>
<Tooltip title="Create a table">
<Fab
className={classes.fab}
color="secondary"
aria-label="Create table"
onClick={() => {
setSettingsDialogState({
mode: TableSettingsDialogModes.create,
data: null,
});
}}
>
<AddIcon />
</Fab>
</Tooltip>
</div>
</Container>
</main>
<Grid item className={classes.createTableContainer}>
<CreateTableDialog
createTable={(tableName: string, collectionName: string) => {
if (createTable) {
createTable(tableName, collectionName);
}
}}
classes={{ fab: classes.createTableFab }}
/>
</Grid>
</Container>
</main>
<TableSettingsDialog
clearDialog={clearDialog}
mode={settingsDialogState.mode}
data={settingsDialogState.data}
/>
</>
);
};