mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
62
ROADMAP.md
Normal file
62
ROADMAP.md
Normal 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
|
||||
16
package.json
16
package.json
@@ -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": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
|
||||
2
src/.env
Normal file
2
src/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
SKIP_PREFLIGHT_CHECK=true
|
||||
REACT_APP_ENV='PRODUCTION'
|
||||
33
src/App.css
33
src/App.css
@@ -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);
|
||||
}
|
||||
}
|
||||
51
src/App.tsx
51
src/App.tsx
@@ -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
23
src/AuthProvider.tsx
Normal 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
27
src/Fields/index.tsx
Normal 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;
|
||||
};
|
||||
112
src/components/ColumnDrawer.tsx
Normal file
112
src/components/ColumnDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/components/CreateTableDialog.tsx
Normal file
87
src/components/CreateTableDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/components/Navigation.tsx
Normal file
123
src/components/Navigation.tsx
Normal 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
248
src/components/Table.tsx
Normal 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</>;
|
||||
}
|
||||
11
src/contexts/authContext.ts
Normal file
11
src/contexts/authContext.ts
Normal 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
23
src/firebase/callables.ts
Normal 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
16
src/firebase/config.ts
Normal 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
19
src/firebase/index.ts
Normal 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
63
src/hooks/useDoc.ts
Normal 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
7
src/hooks/useRouter.ts
Normal 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
32
src/hooks/useSettings.ts
Normal 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
139
src/hooks/useTable.ts
Normal 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;
|
||||
36
src/hooks/useTableConfig.ts
Normal file
36
src/hooks/useTableConfig.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
18
src/util/CustomBrowserRouter.tsx
Normal file
18
src/util/CustomBrowserRouter.tsx
Normal 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
23
src/util/PrivateRoute.tsx
Normal 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
46
src/views/AuthView.tsx
Normal 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
34
src/views/TableView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user