Merge pull request #1 from AntlerEngineering/tables

Tables
This commit is contained in:
shamsmosowi
2019-09-12 09:49:55 +10:00
committed by GitHub
28 changed files with 2966 additions and 1259 deletions

62
ROADMAP.md Normal file
View File

@@ -0,0 +1,62 @@
# Firetable Roadmap
## POC
### Initial fields:
- checkbox(boolean)
- simple text(string)
- email(string)
- phone(string)
- url(string)
- Number(number)
- long text(string)
### Functionality:
- Create Tables (Primary collections)
- Create columns (fields)
- Create rows(documents)
## MVP
### additional fields:
- tags(array of strings)
- single select(string)
- date(Firebase timestamp)
- time(Firebase timestamp)
- index(number)
- file(firebase storage url string)
- [https://material-ui.com/components/chips/#chip-array]Multiple select(array of strings)
- image(firebase storage url string)
- reference(DocRefrence)
- [https://material-ui.com/components/rating/]rating(number)
### Functionality:
- Hide/show columns
- Delete columns
- Delete rows
- Delete tables
- Filters:
- equals to
- Starts with
- contains
## V1
### additional fields:
- Percentage(number)
- table(Document[])
- richtext(html string)
### Functionality:
- Sort rows
- Locked columns
- Table view only mode
- Subcollection tables
- Permissions
- Duplicate columns

View File

@@ -3,13 +3,27 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.4.0",
"@material-ui/icons": "^4.4.1",
"@material-ui/lab": "^4.0.0-alpha.26",
"@types/jest": "24.0.18",
"@types/lodash": "^4.14.138",
"@types/node": "12.7.4",
"@types/react": "16.9.2",
"@types/ramda": "^0.26.21",
"@types/react": "^16.9.2",
"@types/react-dom": "16.9.0",
"@types/react-router-dom": "^4.3.5",
"@types/react-sortable-hoc": "^0.6.5",
"@types/react-virtualized": "^9.21.4",
"array-move": "^2.1.0",
"firebase": "^6.6.0",
"lodash": "^4.17.15",
"ramda": "^0.26.1",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react-router-dom": "^5.0.1",
"react-scripts": "3.1.1",
"react-virtualized": "^9.21.1",
"typescript": "3.6.2"
},
"scripts": {

View File

@@ -16,7 +16,7 @@
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
}
],
"start_url": ".",
"display": "standalone",

2
src/.env Normal file
View File

@@ -0,0 +1,2 @@
SKIP_PREFLIGHT_CHECK=true
REACT_APP_ENV='PRODUCTION'

View File

@@ -1,33 +0,0 @@
.App {
text-align: center;
}
.App-logo {
animation: App-logo-spin infinite 20s linear;
height: 40vmin;
pointer-events: none;
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,26 +1,37 @@
import React from 'react';
import logo from './logo.svg';
import './App.css';
import React from "react";
import { ThemeProvider } from "@material-ui/styles";
import { createMuiTheme } from "@material-ui/core";
import AuthView from "./views/AuthView";
import TableView from "./views/TableView";
import { BrowserRouter as Router, Route } from "react-router-dom";
import { AuthProvider } from "./AuthProvider";
import CustomBrowserRouter from "./util/CustomBrowserRouter";
import PrivateRoute from "./util/PrivateRoute";
const theme = createMuiTheme({
spacing: 4,
palette: {
primary: {
main: "#007bff"
}
}
});
const App: React.FC = () => {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
<ThemeProvider theme={theme}>
<AuthProvider>
<CustomBrowserRouter>
<div>
<Route exact path="/auth" component={AuthView} />
<PrivateRoute path="/table/" component={TableView} />
</div>
</CustomBrowserRouter>
</AuthProvider>
</ThemeProvider>
);
}
};
export default App;

23
src/AuthProvider.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React, { useEffect, useState } from "react";
import { auth } from "./firebase";
import AuthContext from "./contexts/authContext";
export const AuthProvider = ({ children }: any) => {
const [currentUser, setCurrentUser] = useState();
useEffect(() => {
auth.onAuthStateChanged(auth => {
setCurrentUser(auth);
});
}, []);
return (
<AuthContext.Provider
value={{
currentUser
}}
>
{children}
</AuthContext.Provider>
);
};

27
src/Fields/index.tsx Normal file
View File

@@ -0,0 +1,27 @@
import React from "react";
import MailIcon from "@material-ui/icons/MailOutline";
import CheckBoxIcon from "@material-ui/icons/CheckBox";
import SimpleTextIcon from "@material-ui/icons/TextFormat";
import LongTextIcon from "@material-ui/icons/Notes";
import PhoneIcon from "@material-ui/icons/Phone";
import propEq from "ramda/es/propEq";
import find from "ramda/es/find";
export enum FieldType {
simpleText = "SIMPLE_TEXT",
longText = "LONG_TEXT",
email = "EMAIL",
PhoneNumber = "PHONE_NUMBER",
checkBox = "CHECK_BOX"
}
export const FIELDS = [
{ icon: <SimpleTextIcon />, name: "Simple Text", type: FieldType.simpleText },
{ icon: <LongTextIcon />, name: "Long Text", type: FieldType.longText },
{ icon: <MailIcon />, name: "Email", type: FieldType.email },
{ icon: <PhoneIcon />, name: "Phone", type: FieldType.PhoneNumber },
{ icon: <CheckBoxIcon />, name: "Check Box", type: FieldType.checkBox }
];
export const getFieldIcon = (type: FieldType) => {
return find(propEq("type", type))(FIELDS).icon;
};

View File

@@ -0,0 +1,112 @@
import React, { useState, useEffect } from "react";
import { makeStyles } from "@material-ui/core/styles";
import Drawer from "@material-ui/core/Drawer";
import List from "@material-ui/core/List";
import Divider from "@material-ui/core/Divider";
import ListItem from "@material-ui/core/ListItem";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText";
import AddIcon from "@material-ui/icons/Add";
import IconButton from "@material-ui/core/IconButton";
import TextField from "@material-ui/core/TextField";
import _camelCase from "lodash/camelCase";
import { FIELDS } from "../Fields";
const useStyles = makeStyles({
list: {
width: 250
},
fields: {
paddingLeft: 15,
paddingRight: 15
},
fullList: {
width: "auto"
}
});
export default function ColumnDrawer(props: any) {
const { addColumn } = props;
const classes = useStyles();
const [drawerState, toggleDrawer] = useState(false);
const [columnName, setColumnName] = useState("");
const [fieldName, setFieldName] = useState("");
useEffect(() => {
setFieldName(_camelCase(columnName));
}, [columnName]);
const drawer = () => (
<div
className={classes.list}
role="presentation"
onClick={() => {
// toggleDrawer(false);
}}
>
<List className={classes.fields}>
<TextField
autoFocus
onChange={e => {
setColumnName(e.target.value);
}}
margin="dense"
id="name"
label="Column Name"
type="text"
fullWidth
/>
<TextField
value={fieldName}
onChange={e => {
setFieldName(e.target.value);
}}
margin="dense"
id="field"
label="Field Name"
type="text"
fullWidth
/>
</List>
<Divider />
<List>
{FIELDS.map((field: any) => (
<ListItem
button
onClick={() => {
addColumn(columnName, fieldName, field.type);
}}
key={field.type}
>
<ListItemIcon>{field.icon}</ListItemIcon>
<ListItemText primary={field.name} />
</ListItem>
))}
</List>
</div>
);
return (
<div>
<IconButton
aria-label="add"
onClick={() => {
toggleDrawer(true);
}}
>
<AddIcon />
</IconButton>
<Drawer
anchor="right"
open={drawerState}
onClose={() => {
toggleDrawer(false);
}}
>
{drawer()}
</Drawer>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import React, { useState, useEffect } from "react";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText";
import DialogTitle from "@material-ui/core/DialogTitle";
import Fab from "@material-ui/core/Fab";
import AddIcon from "@material-ui/icons/Add";
import _camelCase from "lodash/camelCase";
export default function CreateTableDialog(props: any) {
const { classes, createTable } = props;
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);
handleClose();
}
return (
<div>
<Fab
className={classes.fabButton}
color="secondary"
aria-label="add"
onClick={handleClickOpen}
>
<AddIcon />
</Fab>
<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>
<TextField
autoFocus
onChange={e => {
setTableName(e.target.value);
}}
margin="dense"
id="name"
label="Table Name"
type="email"
fullWidth
/>
<TextField
value={collectionName}
onChange={e => {
setCollectionName(e.target.value);
}}
margin="dense"
id="collection"
label="Collection Name"
type="email"
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
<Button onClick={handleCreate} color="primary">
Create
</Button>
</DialogActions>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import React from "react";
import { createStyles, Theme, makeStyles } from "@material-ui/core/styles";
import AppBar from "@material-ui/core/AppBar";
import CssBaseline from "@material-ui/core/CssBaseline";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import Paper from "@material-ui/core/Paper";
import MenuIcon from "@material-ui/icons/Menu";
import Button from "@material-ui/core/Button";
import Skeleton from "@material-ui/lab/Skeleton";
import useSettings from "../hooks/useSettings";
import CreateTableDialog from "./CreateTableDialog";
import useRouter from "../hooks/useRouter";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
text: {
padding: theme.spacing(2, 2, 0)
},
paper: {
paddingBottom: 20,
paddingTop: 5
},
subheader: {
backgroundColor: theme.palette.background.paper
},
appBar: {
top: "auto",
bottom: 0
},
grow: {
flexGrow: 1
},
fabButton: {
position: "absolute",
zIndex: 1,
top: -30,
right: 20,
margin: "0 auto"
},
button: {
color: "#fff",
marginLeft: 8
},
skeleton: {
marginLeft: 8,
borderRadius: 5
}
})
);
export const Navigation = (props: any) => {
const router = useRouter();
const classes = useStyles();
const [settings, createTable] = useSettings();
return (
<React.Fragment>
<CssBaseline />
<Paper square className={classes.paper}>
<Typography className={classes.text} variant="h5" gutterBottom>
{props.header}
</Typography>
</Paper>
{props.children}
<AppBar position="fixed" color="primary" className={classes.appBar}>
<Toolbar>
<IconButton edge="start" color="inherit" aria-label="open drawer">
<MenuIcon />
</IconButton>
{!settings.tables ? (
<>
<Skeleton
variant="rect"
width={120}
height={40}
className={classes.skeleton}
/>
<Skeleton
variant="rect"
width={120}
height={40}
className={classes.skeleton}
/>
<Skeleton
variant="rect"
width={120}
height={40}
className={classes.skeleton}
/>
<Skeleton
variant="rect"
width={120}
height={40}
className={classes.skeleton}
/>
</>
) : (
<>
{settings.tables.map(
(table: { name: string; collection: string }) => (
<Button
key={table.collection}
onClick={() => {
router.history.push(table.collection);
}}
className={classes.button}
>
{table.name}
</Button>
)
)}
</>
)}
<CreateTableDialog classes={classes} createTable={createTable} />
<div className={classes.grow} />
</Toolbar>
</AppBar>
</React.Fragment>
);
};

248
src/components/Table.tsx Normal file
View File

@@ -0,0 +1,248 @@
import React, { useState } from "react";
import clsx from "clsx";
import {
createStyles,
Theme,
withStyles,
WithStyles
} from "@material-ui/core/styles";
import TableCell from "@material-ui/core/TableCell";
import Paper from "@material-ui/core/Paper";
import {
AutoSizer,
Column,
Table as MuiTable,
TableCellRenderer,
TableHeaderProps
} from "react-virtualized";
import Button from "@material-ui/core/Button";
// import { TextField } from "@material-ui/core";
import { FieldType, getFieldIcon } from "../Fields";
import ColumnDrawer from "./ColumnDrawer";
import IconButton from "@material-ui/core/IconButton";
import DeleteIcon from "@material-ui/icons/Delete";
const styles = (theme: Theme) =>
createStyles({
flexContainer: {
display: "flex",
alignItems: "center",
boxSizing: "border-box"
},
tableRow: {
cursor: "pointer"
},
tableRowHover: {
"&:hover": {
backgroundColor: theme.palette.grey[200]
}
},
tableCell: {
flex: 1
},
noClick: {
cursor: "initial"
}
});
interface ColumnData {
columnData: any;
dataKey: string;
label: string;
numeric?: boolean;
width: number;
}
interface Row {
index: number;
}
interface MuiVirtualizedTableProps extends WithStyles<typeof styles> {
columns: ColumnData[];
headerHeight?: number;
onRowClick?: () => void;
rowCount: number;
rowGetter: (row: Row) => any;
rowHeight?: number;
}
class MuiVirtualizedTable extends React.PureComponent<
MuiVirtualizedTableProps
> {
static defaultProps = {
headerHeight: 48,
rowHeight: 48
};
getRowClassName = ({ index }: Row) => {
const { classes, onRowClick } = this.props;
return clsx(classes.tableRow, classes.flexContainer, {
[classes.tableRowHover]: index !== -1 && onRowClick != null
});
};
cellRenderer: TableCellRenderer = ({
cellData,
columnData,
columnIndex,
dataKey,
isScrolling,
rowData,
rowIndex
}) => {
const { columns, classes, rowHeight, onRowClick } = this.props;
const fieldType = columnData.fieldType;
if (fieldType === "DELETE")
return (
<IconButton
aria-label="delete"
onClick={() => {
columnData.actions.deleteRow(rowIndex, rowData.id);
}}
>
<DeleteIcon />
</IconButton>
);
return (
<TableCell
component="div"
className={clsx(classes.tableCell, classes.flexContainer, {
[classes.noClick]: onRowClick == null
})}
variant="body"
onClick={() => {
console.log(rowIndex, rowData.id, columnData.fieldName);
}}
style={{ height: rowHeight }}
align={
(columnIndex != null && columns[columnIndex].numeric) || false
? "right"
: "left"
}
>
{cellData} {}
</TableCell>
);
};
headerRenderer = ({
label,
columnData,
dataKey,
columnIndex
}: TableHeaderProps & { columnIndex: number }) => {
const { headerHeight, columns, classes } = this.props;
return (
<TableCell
component="div"
className={clsx(
classes.tableCell,
classes.flexContainer,
classes.noClick
)}
variant="head"
style={{ height: headerHeight }}
align={columns[columnIndex].numeric || false ? "right" : "left"}
>
{dataKey === "add" ? (
<ColumnDrawer addColumn={columnData.actions.addColumn} />
) : (
<Button size="small">
{getFieldIcon(columnData.fieldType)} {label}
</Button>
)}
</TableCell>
);
};
render() {
const {
classes,
columns,
rowHeight,
headerHeight,
...tableProps
} = this.props;
return (
<AutoSizer>
{({ height, width }) => (
<MuiTable
height={height}
width={width}
rowHeight={rowHeight!}
headerHeight={headerHeight!}
{...tableProps}
rowClassName={this.getRowClassName}
>
{[
...columns.map(({ dataKey, ...other }, index) => {
return (
<Column
key={dataKey}
headerRenderer={headerProps =>
this.headerRenderer({
...headerProps,
columnIndex: index
})
}
className={classes.flexContainer}
cellRenderer={this.cellRenderer}
dataKey={dataKey}
{...other}
/>
);
})
]}
</MuiTable>
)}
</AutoSizer>
);
}
}
const VirtualizedTable = withStyles(styles)(MuiVirtualizedTable);
export default function Table(props: any) {
const { columns, rows, addColumn, deleteRow } = props;
const [focus, setFocus] = useState(false);
if (columns)
return (
<Paper style={{ height: 400, width: "100%" }}>
<VirtualizedTable
rowCount={rows.length}
rowGetter={({ index }) => rows[index]}
columns={[
...columns.map(
(column: {
fieldName: string;
columnName: string;
type: FieldType;
}) => ({
width: 200,
label: column.columnName,
dataKey: column.fieldName,
columnData: {
fieldType: column.type,
fieldName: column.fieldName,
actions: {}
}
})
),
{
width: 80,
label: "add",
dataKey: "add",
columnData: {
fieldType: "DELETE",
actions: { addColumn, deleteRow }
}
}
]}
/>
</Paper>
);
else return <>insert loading Skeleton here</>;
}

View File

@@ -0,0 +1,11 @@
import React from "react";
interface AuthContextInterface {
currentUser: firebase.User | null | undefined;
}
const AuthContext = React.createContext<AuthContextInterface>({
currentUser: undefined
});
export default AuthContext;

23
src/firebase/callables.ts Normal file
View File

@@ -0,0 +1,23 @@
import { functions } from "./index";
export enum CLOUD_FUNCTIONS {}
export const cloudFunction = (
name: string,
input: any,
success: Function,
fail: Function
) => {
const callable = functions.httpsCallable(name);
callable(input)
.then(result => {
if (success) {
success(result);
}
})
.catch(error => {
if (fail) {
fail(error);
}
});
};

16
src/firebase/config.ts Normal file
View File

@@ -0,0 +1,16 @@
const STAGING_PROJECT_NAME = "antler-vc";
const PRODUCTION_PROJECT_NAME = STAGING_PROJECT_NAME;
const stagingKey = "AIzaSyCADXbyMviWpJ_jPp4leEYMffL70Ahxo_k";
const productionKey = stagingKey;
export const stagingConfig = {
apiKey: stagingKey,
authDomain: `${STAGING_PROJECT_NAME}.firebaseapp.com`,
databaseURL: `https://${STAGING_PROJECT_NAME}.firebaseio.com`,
projectId: STAGING_PROJECT_NAME,
storageBucket: `${STAGING_PROJECT_NAME}.appspot.com`,
messagingSenderId: "236015562107"
};
export const productionConfig = stagingConfig;

19
src/firebase/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import firebase from "firebase/app";
import "firebase/auth";
import "firebase/firestore";
import "firebase/functions";
import { productionConfig, stagingConfig } from "./config";
if (process.env.REACT_APP_ENV === "PRODUCTION") {
console.log("production");
firebase.initializeApp(productionConfig);
} else {
console.log("staging");
firebase.initializeApp(stagingConfig);
}
export const auth = firebase.auth();
export const db = firebase.firestore();
export const functions = firebase.app().functions();
export const googleProvider = new firebase.auth.GoogleAuthProvider();

63
src/hooks/useDoc.ts Normal file
View File

@@ -0,0 +1,63 @@
import { db } from "../firebase";
import { useEffect, useReducer } from "react";
export enum DocActions {
update,
delete // TODO when needed
}
const documentReducer = (prevState: any, newProps: any) => {
switch (newProps.action) {
case DocActions.update:
prevState.ref.update({ ...newProps.data });
return { ...prevState, doc: { ...prevState.doc, ...newProps.data } };
default:
return { ...prevState, ...newProps };
}
};
const documentIntialState = {
path: null,
prevPath: null,
doc: null,
ref: null,
loading: true
};
const useDoc = (intialOverrides: any) => {
const [documentState, documentDispatch] = useReducer(documentReducer, {
...documentIntialState,
...intialOverrides
});
const setDocumentListner = () => {
documentDispatch({ prevPath: documentState.path });
const unsubscribe = db.doc(documentState.path).onSnapshot(snapshot => {
if (snapshot.exists) {
const data = snapshot.data();
const id = snapshot.id;
const doc = { ...data, id };
documentDispatch({
doc,
ref: snapshot.ref,
loading: false
});
}
});
documentDispatch({ unsubscribe });
};
useEffect(() => {
const { path, prevPath, unsubscribe } = documentState;
if (path && path !== prevPath) {
if (unsubscribe) unsubscribe();
setDocumentListner();
}
}, [documentState]);
useEffect(
() => () => {
if (documentState.unsubscribe) documentState.unsubscribe();
},
[]
);
return [documentState, documentDispatch];
};
export default useDoc;

7
src/hooks/useRouter.ts Normal file
View File

@@ -0,0 +1,7 @@
import { useContext } from "react";
import { __RouterContext } from "react-router";
// used to transform routerContext into a hook
// TODO : find alternate solution as this uses an internal variable
export default function useRouter() {
return useContext(__RouterContext);
}

32
src/hooks/useSettings.ts Normal file
View File

@@ -0,0 +1,32 @@
import { useEffect } from "react";
import useDoc, { DocActions } from "./useDoc";
import { db } from "../firebase";
const useSettings = () => {
const [settingsState, documentDispatch] = useDoc({
path: "_FIRETABLE_/settings"
});
useEffect(() => {
//updates tables data on document change
const { doc, tables } = settingsState;
if (doc && tables !== doc.tables) {
documentDispatch({ tables: doc.tables });
}
}, [settingsState]);
const createTable = (name: string, collection: string) => {
const { tables } = settingsState;
// updates the setting doc
documentDispatch({
action: DocActions.update,
data: { tables: [...tables, { name, collection }] }
});
//create the firetable collection doc with empty columns
db.collection(collection)
.doc("_FIRETABLE_")
.set({ columns: [] });
};
return [settingsState, createTable];
};
export default useSettings;

139
src/hooks/useTable.ts Normal file
View File

@@ -0,0 +1,139 @@
import { db } from "../firebase";
import { useEffect, useReducer } from "react";
import equals from "ramda/es/equals";
const CAP = 500;
const tableReducer = (prevState: any, newProps: any) => {
if (newProps.type) {
switch (newProps.type) {
case "more":
if (prevState.limit < prevState.cap)
// rows count hardcap
return { ...prevState, limit: prevState.limit + 10 };
else return { ...prevState };
default:
break;
}
} else {
return { ...prevState, ...newProps };
}
};
const tableIntialState = {
rows: [],
prevFilters: null,
prevPath: null,
path: null,
filters: [],
prevLimit: 0,
limit: 20,
loading: true,
cap: CAP
};
const useTable = (intialOverrides: any) => {
const [tableState, tableDispatch] = useReducer(tableReducer, {
...tableIntialState,
...intialOverrides
});
const getRows = (
filters: {
field: string;
operator: "==" | "<" | ">" | ">=" | "<=";
value: string;
}[],
limit: number,
sort:
| { field: string; direction: "asc" | "desc" }[]
| { field: string; direction: "asc" | "desc" }
) => {
//unsubscribe from old path
if (tableState.prevPath && tableState.path !== tableState.prevPath) {
tableState.unsubscribe();
}
//updates previous values
tableDispatch({
prevFilters: filters,
prevLimit: limit,
prevPath: tableState.path,
loading: true
});
let query:
| firebase.firestore.CollectionReference
| firebase.firestore.Query = db.collection(tableState.path);
filters.forEach(filter => {
query = query.where(filter.field, filter.operator, filter.value);
});
if (sort) {
if (Array.isArray(sort)) {
sort.forEach(order => {
query = query.orderBy(order.field, order.direction);
});
} else {
query = query.orderBy(sort.field, sort.direction);
}
}
const unsubscribe = query.limit(limit).onSnapshot(snapshot => {
if (snapshot.docs.length > 0) {
const rows = snapshot.docs
.map(doc => {
const data = doc.data();
const id = doc.id;
return { ...data, id };
})
.filter(doc => doc.id !== "_FIRETABLE_"); //removes schema file
tableDispatch({
rows,
loading: false
});
} else {
tableDispatch({
rows: [],
loading: false
});
}
});
tableDispatch({ unsubscribe });
};
useEffect(() => {
const {
prevFilters,
filters,
prevLimit,
limit,
prevPath,
path,
sort,
unsubscribe
} = tableState;
if (
!equals(prevFilters, filters) ||
prevLimit !== limit ||
prevPath !== path
) {
if (path) getRows(filters, limit, sort);
}
return () => {
if (unsubscribe) {
tableState.unsubscribe();
}
};
}, [tableState.filters, tableState.limit, tableState.path]);
const deleteRow = (rowIndex: number, documentId: string) => {
//remove row locally
tableState.rows.splice(rowIndex, 1);
tableDispatch({ rows: tableState.rows });
// delete document
db.collection(tableState.path)
.doc(documentId)
.delete();
};
const setTable = (tableCollection: string) => {
tableDispatch({ path: tableCollection });
};
const tableActions = { deleteRow, setTable };
return [tableState, tableActions];
};
export default useTable;

View File

@@ -0,0 +1,36 @@
import { useEffect } from "react";
import useDoc, { DocActions } from "./useDoc";
import { FieldType } from "../Fields";
const useTableConfig = (tablePath: string) => {
const [tableConfigState, documentDispatch] = useDoc({
path: `${tablePath}/_FIRETABLE_`
});
useEffect(() => {
const { doc, columns } = tableConfigState;
if (doc && columns !== doc.columns) {
documentDispatch({ columns: doc.columns });
}
}, [tableConfigState]);
const setTable = (table: string) => {
documentDispatch({ path: `${table}/_FIRETABLE_`, columns: [], doc: null });
};
const addColumn = (
columnName: string,
fieldName: string,
type: FieldType
) => {
const { columns } = tableConfigState;
documentDispatch({
action: DocActions.update,
data: { columns: [...columns, { columnName, fieldName, type }] }
});
console.log(columnName, fieldName, type);
};
const actions = {
addColumn,
setTable
};
return [tableConfigState, actions];
};
export default useTableConfig;

View File

@@ -1,13 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@@ -1,6 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';

View File

@@ -0,0 +1,18 @@
import React from "react";
import { BrowserRouter, Route } from "react-router-dom";
export const RouterContext = React.createContext({});
const CustomBrowserRouter = ({ children }: any) => (
<BrowserRouter>
<Route>
{routeProps => (
<RouterContext.Provider value={routeProps}>
{children}
</RouterContext.Provider>
)}
</Route>
</BrowserRouter>
);
export default CustomBrowserRouter;

23
src/util/PrivateRoute.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React, { useContext } from "react";
import { Route, Redirect } from "react-router-dom";
import AuthContext from "../contexts/authContext";
const PrivateRoute = ({ component: RouteComponent, ...rest }: any) => {
const { currentUser } = useContext(AuthContext);
return (
<Route
{...rest}
render={routeProps =>
!!currentUser ? (
<RouteComponent {...routeProps} />
) : currentUser === null ? (
<Redirect to={"/auth"} />
) : (
<p>authenticating</p>
)
}
/>
);
};
export default PrivateRoute;

46
src/views/AuthView.tsx Normal file
View File

@@ -0,0 +1,46 @@
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import Card from "@material-ui/core/Card";
import CardActions from "@material-ui/core/CardActions";
import CardContent from "@material-ui/core/CardContent";
import Button from "@material-ui/core/Button";
import Typography from "@material-ui/core/Typography";
import { maxWidth } from "@material-ui/system";
import { googleProvider, auth } from "../firebase";
const useStyles = makeStyles({
card: {
margin: "auto",
minWidth: 275,
maxWidth: 300
},
button: {
width: "100%"
},
header: {
textAlign: "center"
}
});
googleProvider.addScope("https://www.googleapis.com/auth/contacts.readonly");
function handleAuth() {
auth.signInWithPopup(googleProvider);
}
export default function AuthView() {
const classes = useStyles();
return (
<Card className={classes.card}>
<CardContent>
<Typography className={classes.header}>Fire Table</Typography>
<Button
onClick={handleAuth}
color="secondary"
className={classes.button}
>
Authenticate With Google
</Button>
</CardContent>
</Card>
);
}

34
src/views/TableView.tsx Normal file
View File

@@ -0,0 +1,34 @@
import React, { useEffect } from "react";
import { makeStyles } from "@material-ui/core/styles";
import { Navigation } from "../components/Navigation";
import useTable from "../hooks/useTable";
import Table from "../components/Table";
import useRouter from "../hooks/useRouter";
import useTableConfig from "../hooks/useTableConfig";
const useStyles = makeStyles({});
export default function AuthView() {
const router = useRouter();
const tableCollection = router.location.pathname.split("/")[2];
const [tableConfig, configActions] = useTableConfig(tableCollection);
const [table, tableActions] = useTable({
path: tableCollection
});
const classes = useStyles();
useEffect(() => {
configActions.setTable(tableCollection);
tableActions.setTable(tableCollection);
}, [tableCollection]);
return (
<Navigation header={tableCollection}>
<Table
columns={tableConfig.columns}
rows={table.rows}
addColumn={configActions.addColumn}
deleteRow={tableActions.deleteRow}
/>
</Navigation>
);
}

2958
yarn.lock

File diff suppressed because it is too large Load Diff