pulled master

This commit is contained in:
shams mosowi
2019-09-27 17:16:39 +10:00
37 changed files with 1305 additions and 430 deletions

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@
.env.development.local
.env.test.local
.env.production.local
.env
npm-debug.log*
yarn-debug.log*

View File

@@ -1,48 +1,63 @@
## Firetable
Firtable is a simple CMS for Google Firebase.
Firetable is a simple CMS for Google Firebase.
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Setup instructions
## Available Scripts
create a firebase project
In the project directory, you can run:
- enable firestore
- enable google auth
create an algolia project
### `npm start`
Cloud functions setup
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
set environment variables
The page will reload if you make edits.<br>
You will also see any lint errors in the console.
```
firebase functions:config:set algolia.app=YOUR_APP_ID algolia.key=ADMIN_API_KEY
```
### `npm test`
deploy the following callable cloud functions to update and delete algolia records
Launches the test runner in the interactive watch mode.<br>
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
```
const functions = require("firebase-functions");
const env = functions.config();
const algolia = require("algoliasearch");
exports.updateAlgoliaRecord = functions.https.onCall(async (data, context) => {
const client = algolia(env.algolia.appid, env.algolia.apikey);
const index = client.initIndex(data.collection);
await index.partialUpdateObject(Object.assign({ objectID: data.id }, data.doc));
return true;
});
### `npm run build`
exports.deleteAlgoliaRecord = functions.https.onCall(async (data, context) => {
const client = algolia(env.algolia.appid, env.algolia.apikey);
const index = client.initIndex(data.collection);
await index.deleteObject(data.id);
return true;
});
```
Builds the app for production to the `build` folder.<br>
It correctly bundles React in production mode and optimizes the build for the best performance.
Clone repo
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
add .env file to the project directory
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
```
REACT_APP_FIREBASE_PROJECT_NAME =
REACT_APP_FIREBASE_PROJECT_KEY =
REACT_APP_ALGOLIA_APP_ID =
REACT_APP_ALGOLIA_SEARCH_KEY =
```
### `npm run eject`
install dependencies
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
```
yarn
```
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Run project locally
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
```
yarn start
```

View File

@@ -1,6 +1,6 @@
# Firetable Roadmap
## POC
## POC
### Initial fields:
@@ -10,7 +10,7 @@
- phone(string) ✅
- url(string) ✅
- Number(number) ✅
- long text(string)
- long text(string)
### Functionality:
@@ -25,44 +25,50 @@
### additional fields:
- single select(string)
- [https://material-ui.com/components/chips/#chip-array] Multiple select(array of strings)
- single select(string)
- Multiple select(array of strings)
- date(Firebase timestamp)✅
- time(Firebase timestamp)✅
- file(firebase storage url string)
- image(firebase storage url string)
- file (single) 🏗️(missing status indicator)
- image (single) 🏗️(missing status indicator)
- single select reference(DocReference)
- multi select reference(DocReference)
- rating ✅
### Functionality:
- Hide/Show columns
- Delete columns
- Edit columns
- Delete tables
- Edit tables
- Hide tables
- Delete columns
- Edit columns
- Fixed column
- Hide/Show columns
- resizable column ✅
- keyboard Navigation:
- Up key to move to the cell above ✅
- Down key to move to the cell bellow, if last cell create a new row ✅
- Tab to go to the next cell ✅
- column / table Create/edit validation
- Delete tables
- Edit tables
- Hide tables
- On new table add, refresh view to the table view✅
- import csv to table
## V1
### additional fields:
- file (multi)
- image (multi)
- Duration
- Percentage(number)
- Slider(number)
- Table(Document[])
- Rich Text(html string)
### Functionality:
- Sort rows
- reorder columns
- Locked columns
- Table view only mode
- SubCollection tables
@@ -89,7 +95,6 @@
### Functionality:
- import csv to table
- Themes
- Table templates
- Dialog View of a row

View File

@@ -8,6 +8,7 @@
"@material-ui/icons": "^4.4.1",
"@material-ui/lab": "^4.0.0-alpha.26",
"@material-ui/pickers": "^3.2.5",
"@types/algoliasearch": "^3.34.2",
"@types/jest": "24.0.18",
"@types/lodash": "^4.14.138",
"@types/node": "12.7.4",
@@ -15,10 +16,15 @@
"@types/react": "^16.9.2",
"@types/react-data-grid": "^4.0.3",
"@types/react-dom": "16.9.0",
"@types/react-instantsearch-dom": "^5.2.6",
"@types/react-router-dom": "^4.3.5",
"@types/react-sortable-hoc": "^0.6.5",
"@types/react-virtualized": "^9.21.4",
<<<<<<< HEAD
"@types/xlsx": "^0.0.36",
=======
"algoliasearch": "^3.34.0",
>>>>>>> 388c726eacf3bd64f948944e2771b63a8afddedc
"array-move": "^2.1.0",
"attr-accept": "^1.1.3",
"convert-csv-to-json": "^0.0.15",

View File

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

View File

@@ -7,7 +7,7 @@ import AuthView from "./views/AuthView";
import TableView from "./views/TableView";
import TablesView from "./views/TablesView";
import { BrowserRouter as Router, Route } from "react-router-dom";
import { Route } from "react-router-dom";
import { AuthProvider } from "./AuthProvider";
import CustomBrowserRouter from "./util/CustomBrowserRouter";
import PrivateRoute from "./util/PrivateRoute";

View File

@@ -1,74 +0,0 @@
import React, { useState, useEffect } from "react";
import _camelCase from "lodash/camelCase";
import {
Button,
TextField,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Fab,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
// TODO: Create an interface for props
export default function ColumnDialog(props: any) {
const { classes, columnName, updateColumn } = props;
const [open, setOpen] = React.useState(false);
function handleClickOpen() {
setOpen(true);
}
function handleClose() {
setOpen(false);
}
function handleUpdate() {
// updateColumn(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="text"
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
<Button onClick={handleUpdate} color="primary">
update
</Button>
</DialogActions>
</Dialog>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import AddIcon from "@material-ui/icons/Add";
import _camelCase from "lodash/camelCase";
import useRouter from "../hooks/useRouter";
import {
Button,
TextField,
@@ -15,6 +15,7 @@ import {
// TODO: Create an interface for props
export default function CreateTableDialog(props: any) {
const router = useRouter();
const { classes, createTable } = props;
const [open, setOpen] = React.useState(false);
const [tableName, setTableName] = useState("");
@@ -33,6 +34,7 @@ export default function CreateTableDialog(props: any) {
}
function handleCreate() {
createTable(tableName, collectionName);
router.history.push(collectionName);
handleClose();
}

View File

@@ -16,7 +16,7 @@ const CheckBox = (props: Props) => {
name={`checkBox-controlled-${row.id}`}
checked={!!value}
onChange={e => {
onSubmit(row.ref, !value);
onSubmit(!value);
}}
/>
);

View File

@@ -22,7 +22,7 @@ const Date = (props: Props) => {
const { value, row, onSubmit, fieldType } = props;
function handleDateChange(date: Date | null) {
if (date) {
onSubmit(row.ref, date);
onSubmit(date);
}
}

View File

@@ -0,0 +1,120 @@
import React, { useState, useEffect } from "react";
import SearchIcon from "@material-ui/icons/Search";
import IconButton from "@material-ui/core/IconButton";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Popper from "@material-ui/core/Popper";
import Paper from "@material-ui/core/Paper";
import ClickAwayListener from "@material-ui/core/ClickAwayListener";
import algoliasearch from "algoliasearch/lite";
import { TextField } from "@material-ui/core";
const searchClient = algoliasearch(
process.env.REACT_APP_ALGOLIA_APP_ID
? process.env.REACT_APP_ALGOLIA_APP_ID
: "",
process.env.REACT_APP_ALGOLIA_SEARCH_KEY
? process.env.REACT_APP_ALGOLIA_SEARCH_KEY
: ""
);
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
position: "relative",
display: "flex",
flexWrap: "wrap",
},
typography: {
padding: theme.spacing(2),
},
textArea: {
fontSize: 14,
minWidth: 230,
},
paper: { minWidth: 200 },
})
);
interface Props {
value: any;
row: { ref: firebase.firestore.DocumentReference; id: string };
onSubmit: Function;
collectionPath: string;
}
const DocSelect = (props: Props) => {
const { value, row, onSubmit, collectionPath } = props;
const [query, setQuery] = useState(value ? value : "");
const [hits, setHits] = useState<{}>([]);
const algoliaIndex = searchClient.initIndex(collectionPath);
const search = async (query: string) => {
const resp = await algoliaIndex.search({ query });
setHits(resp.hits);
};
useEffect(() => {
search(query);
}, [query]);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const classes = useStyles();
const handleClick = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
setAnchorEl(event.currentTarget);
};
const open = Boolean(anchorEl);
const id = open ? "no-transition-popper" : undefined;
const onClickAway = (event: any) => {
if (event.target.id !== id) {
// onSubmit();
// setAnchorEl(null);
}
};
return (
<div className={classes.root}>
<ClickAwayListener onClickAway={onClickAway}>
<div>
<IconButton onClick={handleClick}>
<SearchIcon />
</IconButton>
{value}
<Popper id={id} open={open} anchorEl={anchorEl}>
<Paper>
<TextField
id={id}
placeholder={`searching ${collectionPath}`}
onChange={(e: any) => {
setQuery(e.target.value);
}}
/>
<div>
{/* <InstantSearch
indexName={collectionPath}
searchClient={searchClient}
>
<SearchBox /> */}
{/* </InstantSearch> */}
</div>
</Paper>
</Popper>
</div>
</ClickAwayListener>
</div>
);
};
const Hit = (props: any) => {
return (
<div>
<h3>{props.hit.firstName}</h3>
<p>{props.hit.email}</p>
</div>
);
};
export default DocSelect;

View File

@@ -0,0 +1,71 @@
import React, { useCallback } from "react";
import { useDropzone } from "react-dropzone";
import useUploader from "../../hooks/useFiretable/useUploader";
import { FieldType } from ".";
import Chip from "@material-ui/core/Chip";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
// TODO: indicate state completion / error
// TODO: Create an interface for props
interface Props {
value: any;
row: { ref: firebase.firestore.DocumentReference; id: string };
onSubmit: Function;
fieldType: FieldType;
fieldName: string;
}
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
display: "flex",
flexWrap: "wrap",
},
chip: {},
})
);
const File = (props: Props) => {
const { fieldName, value, row, onSubmit } = props;
const classes = useStyles();
const [uploaderState, upload] = useUploader();
const onDrop = useCallback(acceptedFiles => {
// Do something with the files
const imageFile = acceptedFiles[0];
if (imageFile) {
upload(row.ref, fieldName, [imageFile]);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: false,
});
const handleDelete = () => {
onSubmit([]);
};
return (
<div {...getRootProps()}>
<input {...getInputProps()} />
{value && value.length !== 0 ? (
<Chip
key={value[0].name}
label={value[0].name}
className={classes.chip}
onClick={() => {
window.open(value[0].downloadURL);
}}
onDelete={handleDelete}
/>
) : isDragActive ? (
<p>Drop the files here ...</p>
) : (
<p>click to select files</p>
)}
</div>
);
};
export default File;

View File

@@ -11,17 +11,18 @@ interface Props {
row: { ref: firebase.firestore.DocumentReference; id: string };
onSubmit: Function;
fieldType: FieldType;
fieldName: string;
}
const Image = (props: any) => {
const { columnData, cellData, cellActions, rowData, rowIndex } = props;
const Image = (props: Props) => {
const { fieldName, value, row } = props;
const [uploaderState, upload] = useUploader();
const [localImage, setLocalImage] = useState<string | null>(null);
const onDrop = useCallback(acceptedFiles => {
// Do something with the files
const imageFile = acceptedFiles[0];
if (imageFile) {
upload(rowData.ref, columnData.fieldName, [imageFile]);
upload(row.ref, fieldName, [imageFile]);
let url = URL.createObjectURL(imageFile);
setLocalImage(url);
}
@@ -37,10 +38,10 @@ const Image = (props: any) => {
<input {...getInputProps()} />
{localImage ? (
<div>
<img style={{ height: "150px" }} src={localImage} />
<img style={{ height: "80px" }} src={localImage} />
</div>
) : cellData ? (
<img style={{ height: "150px" }} src={cellData[0].downloadURL} />
) : value ? (
<img style={{ height: "80px" }} src={value[0].downloadURL} />
) : isDragActive ? (
<p>Drop the files here ...</p>
) : (

View File

@@ -1,22 +1,89 @@
import React from "react";
import React, { useState } from "react";
import ExpandIcon from "@material-ui/icons/AspectRatio";
import IconButton from "@material-ui/core/IconButton";
import Typography from "@material-ui/core/Typography";
import Button from "@material-ui/core/Button";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Popper from "@material-ui/core/Popper";
import Fade from "@material-ui/core/Fade";
import Paper from "@material-ui/core/Paper";
import TextareaAutosize from "@material-ui/core/TextareaAutosize";
import ClickAwayListener from "@material-ui/core/ClickAwayListener";
import { onSubmit } from "components/Table/grid-fns";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
position: "relative",
display: "flex",
flexWrap: "wrap",
},
typography: {
padding: theme.spacing(2),
},
textArea: {
fontSize: 14,
minWidth: 230,
},
})
);
interface Props {
value: firebase.firestore.Timestamp | null;
row: any;
value: any;
row: { ref: firebase.firestore.DocumentReference; id: string };
onSubmit: Function;
}
const UrlLink = (props: any) => {
const { value, cellActions } = props;
return value ? (
<>
<IconButton>
<ExpandIcon />
</IconButton>
<p>{value}</p>
</>
) : null;
const LongText = (props: Props) => {
const { value, row, onSubmit } = props;
const [text, setText] = useState(value ? value : "");
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const classes = useStyles();
const handleClick = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
setAnchorEl(event.currentTarget);
};
const open = Boolean(anchorEl);
const id = open ? "no-transition-popper" : undefined;
const onClickAway = (event: any) => {
if (event.target.id !== id) {
onSubmit(text);
setAnchorEl(null);
}
};
return (
<div className={classes.root}>
<ClickAwayListener onClickAway={onClickAway}>
<div>
<IconButton onClick={handleClick}>
<ExpandIcon />
</IconButton>
{text}
<Popper id={id} open={open} anchorEl={anchorEl}>
<Paper>
<Typography className={classes.typography}>
<TextareaAutosize
id={id}
className={classes.textArea}
rowsMax={6}
aria-label="maximum height"
placeholder="enter text"
defaultValue={text}
autoFocus
onKeyPress={(e: any) => {
setText(e.target.value);
}}
/>
</Typography>
</Paper>
</Popper>
</div>
</ClickAwayListener>
</div>
);
};
export default UrlLink;
export default LongText;

View File

@@ -0,0 +1,118 @@
import React from "react";
import EditIcon from "@material-ui/icons/Edit";
import WarningIcon from "@material-ui/icons/Warning";
import { Select } from "@material-ui/core";
import {
createStyles,
makeStyles,
useTheme,
Theme,
} from "@material-ui/core/styles";
import Input from "@material-ui/core/Input";
import Grid from "@material-ui/core/Grid";
import MenuItem from "@material-ui/core/MenuItem";
import Chip from "@material-ui/core/Chip";
import Typography from "@material-ui/core/Typography";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
display: "flex",
flexWrap: "wrap",
},
formControl: {
margin: theme.spacing(1),
minWidth: 120,
maxWidth: 300,
},
chips: {
display: "flex",
flexWrap: "wrap",
},
chip: {
margin: 2,
},
noLabel: {
marginTop: theme.spacing(3),
},
noOptions: {
position: "absolute",
top: -15,
},
})
);
interface Props {
value: string[] | null;
row: { ref: firebase.firestore.DocumentReference; id: string };
options: string[];
onSubmit: Function;
}
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
PaperProps: {
style: {
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
width: 250,
},
},
};
const MultiSelect = (props: Props) => {
const classes = useStyles();
const { value, row, options, onSubmit } = props;
const handleChange = (e: any, v: any) => {
if (!value) {
// creates new array
onSubmit([v.props.value]);
} else if (!value.includes(v.props.value)) {
// adds to array
onSubmit([...value, v.props.value]);
} else {
// removes from array
let _updatedValues = [...value];
const index = _updatedValues.indexOf(v.props.value);
_updatedValues.splice(index, 1);
onSubmit(_updatedValues);
}
};
if (options && options.length !== 0)
return (
<Select
multiple
value={value ? value : []}
defaultValue={[]}
onChange={handleChange}
input={<Input id="select-multiple-chip" />}
renderValue={selected => (
<div className={classes.chips}>
{(selected as string[]).map(value => (
<Chip key={value} label={value} className={classes.chip} />
))}
</div>
)}
MenuProps={MenuProps}
>
{options.map(option => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</Select>
);
else
return (
<Grid className={classes.noOptions} direction="row">
{/* <Grid item>
<WarningIcon />
</Grid>{" "} */}
<Grid item>
<Typography>add options!</Typography>
</Grid>
</Grid>
);
};
export default MultiSelect;

View File

@@ -17,7 +17,7 @@ const Rating = (props: Props) => {
name={`rating-controlled-${row.id}`}
value={value}
onChange={(event, newValue) => {
onSubmit(row.ref, newValue);
onSubmit(newValue);
}}
/>
);

View File

@@ -11,7 +11,7 @@ const UrlLink = (props: Props) => {
return value ? (
<>
<EditIcon />
<a href={value} target="_blank">
<a href={value} target="_blank" rel="noopener noreferrer">
{value}
</a>
</>

View File

@@ -28,6 +28,10 @@ export enum FieldType {
rating = "RATING",
image = "IMAGE",
file = "FILE",
singleSelect = "SINGLE_SELECT",
multiSelect = "MULTI_SELECT",
documentSelect = "DOCUMENT_SELECT",
documentsSelect = "DOCUMENTS_SELECT",
}
export const FIELDS = [
@@ -43,6 +47,10 @@ export const FIELDS = [
{ icon: <RatingIcon />, name: "Rating", type: FieldType.rating },
{ icon: <ImageIcon />, name: "Image", type: FieldType.image },
{ icon: <FileIcon />, name: "File", type: FieldType.file },
{ icon: <FileIcon />, name: "Single Select", type: FieldType.singleSelect },
{ icon: <FileIcon />, name: "Multi Select", type: FieldType.multiSelect },
{ icon: <FileIcon />, name: "Doc Select", type: FieldType.documentSelect },
{ icon: <FileIcon />, name: "Docs Select", type: FieldType.documentsSelect },
];
export const getFieldIcon = (type: FieldType) => {

View File

@@ -18,6 +18,7 @@ import CreateTableDialog from "./CreateTableDialog";
import useSettings from "../hooks/useSettings";
import useRouter from "../hooks/useRouter";
import TablesContext from "../contexts/tablesContext";
const useStyles = makeStyles(theme =>
createStyles({
@@ -63,69 +64,71 @@ const Navigation = (props: any) => {
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>
)
)}
</>
)}
<TablesContext.Provider value={{ value: settings.tables }}>
<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>
<CreateTableDialog classes={classes} createTable={createTable} />
<div className={classes.grow} />
</Toolbar>
</AppBar>
</React.Fragment>
</TablesContext.Provider>
);
};
export default Navigation;

View File

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

View File

@@ -0,0 +1,70 @@
import React, { useEffect } from "react";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Chip from "@material-ui/core/Chip";
import { TextField, Grid, Divider } from "@material-ui/core";
import _includes from "lodash/includes";
import _camelCase from "lodash/camelCase";
const useStyles = makeStyles(Theme =>
createStyles({
root: {
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
maxWidth: 300,
padding: Theme.spacing(1),
},
chip: {
margin: Theme.spacing(0.5),
},
})
);
export default function SelectOptionsInput(props: any) {
const { options, setValue } = props;
const classes = useStyles();
const handleAdd = (newOption: string) => {
// setOptions([...options, newOption]);
setValue("options", [...options, newOption]);
};
const handleDelete = (optionToDelete: string) => () => {
const newOptions = options.filter(
(option: string) => option !== optionToDelete
);
setValue("options", newOptions);
};
useEffect(() => {
setValue({ data: { options } });
}, [options]);
return (
<Grid container direction="column" className={classes.root}>
<Grid item>
<TextField
label="New Option"
onKeyPress={(e: any) => {
const value = e.target.value;
if (e.key === "Enter") {
handleAdd(value);
e.target.value = "";
}
}}
/>
</Grid>
<Grid item>
{options.map((option: string) => {
return (
<Chip
key={option}
label={option}
onDelete={handleDelete(option)}
className={classes.chip}
/>
);
})}
</Grid>
</Grid>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useContext, useState } from "react";
import Button from "@material-ui/core/Button";
import Typography from "@material-ui/core/Typography";
import InputLabel from "@material-ui/core/InputLabel";
@@ -11,8 +11,8 @@ import Fade from "@material-ui/core/Fade";
import Paper from "@material-ui/core/Paper";
import ClickAwayListener from "@material-ui/core/ClickAwayListener";
import { createStyles, makeStyles } from "@material-ui/core/styles";
import { TextField, Grid } from "@material-ui/core";
import { FieldsDropDown, isFieldType } from "../Fields";
import { TextField, Grid, Select } from "@material-ui/core";
import { FieldsDropDown, isFieldType, FieldType } from "../../Fields";
import ToggleButton from "@material-ui/lab/ToggleButton";
import ToggleButtonGroup from "@material-ui/lab/ToggleButtonGroup";
@@ -20,9 +20,12 @@ import LockIcon from "@material-ui/icons/Lock";
import LockOpenIcon from "@material-ui/icons/LockOpen";
import VisibilityIcon from "@material-ui/icons/Visibility";
import VisibilityOffIcon from "@material-ui/icons/VisibilityOff";
import FormatItalicIcon from "@material-ui/icons/FormatItalic";
import FormatUnderlinedIcon from "@material-ui/icons/FormatUnderlined";
import FormatColorFillIcon from "@material-ui/icons/FormatColorFill";
import DeleteIcon from "@material-ui/icons/Delete";
import SelectOptionsInput from "./SelectOptionsInput";
import DocInput from "./DocInput";
const useStyles = makeStyles(Theme =>
createStyles({
@@ -66,13 +69,16 @@ const useStyles = makeStyles(Theme =>
})
);
const HeaderPopper = (props: any) => {
const ColumnEditor = (props: any) => {
const { anchorEl, column, handleClose, actions } = props;
const [values, setValues] = React.useState({
const [values, setValues] = useState({
type: null,
name: "",
options: [],
collectionPath: "",
});
const [flags, setFlags] = React.useState(() => [""]);
const [flags, setFlags] = useState(() => [""]);
const classes = useStyles();
function handleChange(
@@ -91,7 +97,7 @@ const HeaderPopper = (props: any) => {
};
useEffect(() => {
if (column && !column.isNew)
if (column && !column.isNew) {
setValues(oldValues => ({
...oldValues,
name: column.name,
@@ -99,11 +105,25 @@ const HeaderPopper = (props: any) => {
key: column.key,
isNew: column.isNew,
}));
if (column.options) {
setValue("options", column.options);
} else {
setValue("options", []);
}
if (column.collectionPath) {
setValue("collectionPath", column.collectionPath);
}
}
}, [column]);
const clearValues = () => {
setValues({ type: null, name: "", options: [], collectionPath: "" });
};
const onClickAway = (event: any) => {
const dropDownClicked = isFieldType(event.target.dataset.value);
if (!dropDownClicked) {
const elementId = event.target.id;
console.log(event, elementId);
if (!elementId.includes("select")) {
handleClose();
clearValues();
}
};
const handleToggle = (
@@ -114,9 +134,43 @@ const HeaderPopper = (props: any) => {
};
const createNewColumn = () => {
const { name, type } = values;
actions.add(name, type);
const { name, type, options, collectionPath } = values;
actions.add(name, type, { options, collectionPath });
handleClose();
clearValues();
};
const deleteColumn = () => {
actions.remove(props.column.idx);
handleClose();
clearValues();
};
const updateColumn = () => {
let updatables: { field: string; value: any }[] = [
{ field: "name", value: values.name },
{ field: "type", value: values.type },
// { field: "resizable", value: flags.includes("resizable") },
];
if (
values.type === FieldType.multiSelect ||
values.type === FieldType.singleSelect
) {
updatables.push({ field: "options", value: values.options });
}
if (
values.type === FieldType.documentSelect ||
values.type === FieldType.documentsSelect
) {
updatables.push({
field: "collectionPath",
value: values.collectionPath,
});
}
actions.update(props.column.idx, updatables);
handleClose();
clearValues();
};
if (column) {
@@ -132,6 +186,8 @@ const HeaderPopper = (props: any) => {
<Fade {...TransitionProps} timeout={350}>
<Paper className={classes.container}>
<Grid container direction="column">
{/*
// TODO: functional flags
<ToggleButtonGroup
size="small"
value={flags}
@@ -139,33 +195,32 @@ const HeaderPopper = (props: any) => {
onChange={handleToggle}
arial-label="column settings"
>
<ToggleButton value="editable" aria-label="bold">
<ToggleButton value="editable" aria-label="editable">
{flags.includes("editable") ? (
<LockOpenIcon />
) : (
<LockIcon />
)}
</ToggleButton>
<ToggleButton value="visible" aria-label="italic">
<ToggleButton value="visible" aria-label="visible">
{flags.includes("visible") ? (
<VisibilityIcon />
) : (
<VisibilityOffIcon />
)}
</ToggleButton>
<ToggleButton value="freeze" aria-label="underlined">
<ToggleButton value="freeze" aria-label="freeze">
<FormatUnderlinedIcon />
</ToggleButton>
<ToggleButton value="resize" aria-label="color">
<ToggleButton value="resizable" aria-label="resizable">
<FormatColorFillIcon />
</ToggleButton>
</ToggleButtonGroup>
</ToggleButtonGroup> */}
<TextField
label="Column name"
name="name"
defaultValue={values.name}
// onChange={handleChange}
onChange={e => {
setValue("name", e.target.value);
}}
@@ -174,10 +229,42 @@ const HeaderPopper = (props: any) => {
<FormControl className={classes.formControl}>
<InputLabel htmlFor="Field-select">Field Type</InputLabel>
{FieldsDropDown(values.type, handleChange)}
{column.isNew && (
<Button onClick={createNewColumn}>Add</Button>
{(values.type === FieldType.singleSelect ||
values.type === FieldType.multiSelect) && (
<SelectOptionsInput
setValue={setValue}
options={values.options}
/>
)}
<Button color="secondary" onClick={handleClose}>
{(values.type === FieldType.documentSelect ||
values.type === FieldType.documentsSelect) && (
<DocInput
setValue={setValue}
collectionPath={values.collectionPath}
/>
)}
{column.isNew ? (
<Button onClick={createNewColumn}>Add</Button>
) : (
<Button onClick={updateColumn}>update</Button>
)}
{!column.isNew && (
<Button
variant="outlined"
color="secondary"
onClick={deleteColumn}
>
<DeleteIcon /> Delete
</Button>
)}
<Button
color="secondary"
onClick={() => {
handleClose();
clearValues();
}}
>
cancel
</Button>
</FormControl>
@@ -192,4 +279,4 @@ const HeaderPopper = (props: any) => {
return <div />;
};
export default HeaderPopper;
export default ColumnEditor;

View File

@@ -0,0 +1,153 @@
import React from "react";
import { FieldType } from "../Fields";
import Date from "../Fields/Date";
import Rating from "../Fields/Rating";
import CheckBox from "../Fields/CheckBox";
import UrlLink from "../Fields/UrlLink";
import { Editors } from "react-data-grid-addons";
import MultiSelect from "../Fields/MultiSelect";
import Image from "../Fields/Image";
import File from "../Fields/File";
import LongText from "../Fields/LongText";
import DocSelect from "../Fields/DocSelect";
import { CLOUD_FUNCTIONS } from "firebase/callables";
import { functions } from "../../firebase";
const algoliaUpdateDoc = functions.httpsCallable(
CLOUD_FUNCTIONS.updateAlgoliaRecord
);
const { AutoComplete } = Editors;
export const editable = (fieldType: FieldType) => {
switch (fieldType) {
case FieldType.date:
case FieldType.dateTime:
case FieldType.rating:
case FieldType.checkBox:
case FieldType.multiSelect:
case FieldType.image:
case FieldType.file:
case FieldType.longText:
case FieldType.documentSelect:
return false;
default:
return true;
}
};
export const onSubmit = (key: string, row: any) => async (value: any) => {
const collection = row.ref.parent.path;
const data = { collection, id: row.ref.id, doc: { [key]: value } };
if (value !== null || value !== undefined) {
row.ref.update({ [key]: value });
const callableRes = await algoliaUpdateDoc(data);
console.log(callableRes);
}
};
export const DateFormatter = (key: string, fieldType: FieldType) => (
props: any
) => {
return (
<Date
{...props}
onSubmit={onSubmit(key, props.row)}
fieldType={fieldType}
/>
);
};
export const onGridRowsUpdated = (props: any) => {
const { fromRowData, updated } = props;
onSubmit(Object.keys(updated)[0], fromRowData)(Object.values(updated)[0]);
};
export const onCellSelected = (args: any) => {
console.log(args);
};
export const cellFormatter = (column: any) => {
const { type, key, options } = column;
switch (type) {
case FieldType.date:
case FieldType.dateTime:
return DateFormatter(key, type);
case FieldType.rating:
return (props: any) => {
return (
<Rating
{...props}
onSubmit={onSubmit(key, props.row)}
value={typeof props.value === "number" ? props.value : 0}
/>
);
};
case FieldType.checkBox:
return (props: any) => {
return <CheckBox {...props} onSubmit={onSubmit(key, props.row)} />;
};
case FieldType.url:
return (props: any) => {
return <UrlLink {...props} />;
};
case FieldType.multiSelect:
return (props: any) => {
return (
<MultiSelect
{...props}
onSubmit={onSubmit(key, props.row)}
options={options}
/>
);
};
case FieldType.image:
return (props: any) => {
return (
<Image
{...props}
onSubmit={onSubmit(key, props.row)}
fieldName={key}
/>
);
};
case FieldType.file:
return (props: any) => {
return (
<File
{...props}
onSubmit={onSubmit(key, props.row)}
fieldName={key}
/>
);
};
case FieldType.longText:
return (props: any) => {
return <LongText {...props} onSubmit={onSubmit(key, props.row)} />;
};
case FieldType.documentSelect:
return (props: any) => {
return (
<DocSelect
{...props}
onSubmit={onSubmit(key, props.row)}
collectionPath={column.collectionPath}
/>
);
};
default:
return false;
}
};
export const singleSelectEditor = (options: string[]) => {
if (options) {
const _options = options.map(option => ({
id: option,
value: option,
title: option,
text: option,
}));
return <AutoComplete options={_options} />;
}
return <AutoComplete options={[]} />;
};

View File

@@ -1,20 +1,26 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import ReactDataGrid from "react-data-grid";
import useFiretable from "../../hooks/useFiretable";
import Typography from "@material-ui/core/Typography";
import { createStyles, Theme, makeStyles } from "@material-ui/core/styles";
import IconButton from "@material-ui/core/IconButton";
import Button from "@material-ui/core/Button";
import EditIcon from "@material-ui/icons/Edit";
import HeaderPopper from "./HeaderPopper";
import { FieldType, getFieldIcon } from "../Fields";
import Date from "../Fields/Date";
import Rating from "../Fields/Rating";
import CheckBox from "../Fields/CheckBox";
import UrlLink from "../Fields/UrlLink";
import ColumnEditor from "./ColumnEditor/index";
import { functions } from "../../firebase";
import {
cellFormatter,
onCellSelected,
onGridRowsUpdated,
singleSelectEditor,
editable,
} from "./grid-fns";
import { CLOUD_FUNCTIONS } from "firebase/callables";
const deleteAlgoliaRecord = functions.httpsCallable(
CLOUD_FUNCTIONS.deleteAlgoliaRecord
);
const useStyles = makeStyles(Theme =>
createStyles({
@@ -32,66 +38,12 @@ const useStyles = makeStyles(Theme =>
})
);
const copyPaste = (props: any) => {
console.log(props);
};
const editable = (fieldType: FieldType) => {
switch (fieldType) {
case FieldType.date:
case FieldType.dateTime:
case FieldType.rating:
case FieldType.checkBox:
return false;
default:
return true;
}
};
const onSubmit = (key: string) => (
ref: firebase.firestore.DocumentReference,
value: any
) => {
if (value !== null || value !== undefined) {
ref.update({ [key]: value });
}
};
const DateFormatter = (key: string, fieldType: FieldType) => (props: any) => {
return <Date {...props} onSubmit={onSubmit(key)} fieldType={fieldType} />;
};
const formatter = (fieldType: FieldType, key: string) => {
switch (fieldType) {
case FieldType.date:
case FieldType.dateTime:
return DateFormatter(key, fieldType);
case FieldType.rating:
return (props: any) => {
return (
<Rating
{...props}
onSubmit={onSubmit(key)}
value={typeof props.value === "number" ? props.value : 0}
/>
);
};
case FieldType.checkBox:
return (props: any) => {
return <CheckBox {...props} onSubmit={onSubmit(key)} />;
};
case FieldType.url:
return (props: any) => {
return <UrlLink {...props} onSubmit={onSubmit(key)} />;
};
default:
return false;
}
};
function Table(props: any) {
const { collection } = props;
const { tableState, tableActions } = useFiretable(collection);
useEffect(() => {
tableActions.set(collection);
}, [collection]);
const classes = useStyles();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
@@ -109,14 +61,6 @@ function Table(props: any) {
setHeader(headerProps);
};
const onGridRowsUpdated = (props: any) => {
const { fromRowData, updated } = props;
fromRowData.ref.update(updated);
};
const onCellSelected = (args: any) => {
// handleCloseHeader();
console.log(args);
};
const headerRenderer = (props: any) => {
const { column } = props;
switch (column.key) {
@@ -152,13 +96,14 @@ function Table(props: any) {
if (tableState.columns) {
let columns = tableState.columns.map((column: any) => ({
width: 220,
resizable: true,
key: column.fieldName,
name: column.columnName,
editable: editable(column.type),
resizeable: true,
resizable: true,
headerRenderer: headerRenderer,
formatter: formatter(column.type, column.fieldName),
formatter: cellFormatter(column),
editor:
column.type === FieldType.singleSelect
? singleSelectEditor(column.options)
: false,
...column,
}));
columns.push({
@@ -167,25 +112,52 @@ function Table(props: any) {
name: "Add column",
width: 160,
headerRenderer: headerRenderer,
formatter: (props: any) => (
<Button
onClick={async () => {
props.row.ref.delete();
await deleteAlgoliaRecord({
id: props.row.ref.id,
collection: props.row.ref.parent.path,
});
}}
>
Delete row
</Button>
),
});
const rows = tableState.rows;
const rows = tableState.rows; //.map((row: any) => ({ height: 100, ...row }));
return (
<>
<ReactDataGrid
rowHeight={40}
columns={columns}
rowGetter={i => rows[i]}
rowsCount={rows.length}
onGridRowsUpdated={onGridRowsUpdated}
enableCellSelect={true}
onCellCopyPaste={copyPaste}
minHeight={500}
onCellSelected={onCellSelected}
onColumnResize={(idx, width) =>
tableActions.column.resize(idx, width)
}
emptyRowsView={() => {
return (
<div
style={{
textAlign: "center",
backgroundColor: "#ddd",
padding: "100px",
}}
>
<h3>no data to show</h3>
<Button onClick={tableActions.row.add}>Add Row</Button>
</div>
);
}}
/>
<Button onClick={tableActions.row.add}>Add Row</Button>
<HeaderPopper
<ColumnEditor
handleClose={handleCloseHeader}
anchorEl={anchorEl}
column={header && header.column}

View File

@@ -0,0 +1,11 @@
import React from "react";
interface TablesContextInterface {
value: any[] | undefined;
}
const TablesContext = React.createContext<TablesContextInterface>({
value: undefined,
});
export default TablesContext;

View File

@@ -1,6 +1,9 @@
import { functions } from "./index";
export enum CLOUD_FUNCTIONS {}
export enum CLOUD_FUNCTIONS {
updateAlgoliaRecord = "updateAlgoliaRecord",
deleteAlgoliaRecord = "deleteAlgoliaRecord",
}
export const cloudFunction = (
name: string,

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -4,18 +4,18 @@ import "firebase/firestore";
import "firebase/functions";
import "firebase/storage";
import { productionConfig, stagingConfig } from "./config";
const config = {
apiKey: process.env.REACT_APP_FIREBASE_PROJECT_KEY,
authDomain: `${process.env.REACT_APP_FIREBASE_PROJECT_NAME}.firebaseapp.com`,
databaseURL: `https://${process.env.REACT_APP_FIREBASE_PROJECT_NAME}.firebaseio.com`,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_NAME,
storageBucket: `${process.env.REACT_APP_FIREBASE_PROJECT_NAME}.appspot.com`,
};
if (process.env.REACT_APP_ENV === "PRODUCTION") {
console.log("production");
firebase.initializeApp(productionConfig);
} else {
console.log("staging");
firebase.initializeApp(stagingConfig);
}
firebase.initializeApp(config);
export const auth = firebase.auth();
export const db = firebase.firestore();
export const bucket = firebase.storage();
export const functions = firebase.app().functions();
export const functions = firebase.functions();
export const googleProvider = new firebase.auth.GoogleAuthProvider();

View File

@@ -1,16 +1,19 @@
import useTable from "./useTable";
import useTableConfig from "./useTableConfig";
import useCell, { Cell } from "./useCell";
export type FiretableActions = {
cell: { set: Function; update: Function };
column: { add: Function; resize: Function; rename: Function };
column: {
add: Function;
resize: Function;
rename: Function;
remove: Function;
update: Function;
};
row: { add: any; delete: Function };
table: { set: Function };
set: Function;
};
export type FiretableState = {
cell: Cell;
columns: any;
rows: any;
};
@@ -20,27 +23,28 @@ const useFiretable = (collectionName: string) => {
const [tableState, tableActions] = useTable({
path: collectionName,
});
const [cellState, cellActions] = useCell({});
const setTable = (collectionName: string) => {
configActions.setTable(collectionName);
tableActions.setTable(collectionName);
cellActions.set(null);
};
const state: FiretableState = {
cell: cellState.cell,
columns: tableConfig.columns,
rows: tableState.rows,
};
const actions: FiretableActions = {
cell: { ...cellActions },
column: {
add: configActions.add,
resize: configActions.resize,
rename: configActions.rename,
update: configActions.update,
remove: configActions.remove,
},
row: { add: tableActions.addRow, delete: tableActions.deleteRow },
table: { set: setTable },
row: {
add: tableActions.addRow,
delete: tableActions.deleteRow,
},
set: setTable,
};
return { tableState: state, tableActions: actions };

View File

@@ -1,57 +0,0 @@
import { db } from "../../firebase";
import { useEffect, useReducer } from "react";
import equals from "ramda/es/equals";
export type Cell = {
fieldName: string;
rowIndex: number;
docRef: firebase.firestore.DocumentReference;
value: any;
};
const cellReducer = (prevState: any, newProps: any) => {
return { ...prevState, ...newProps };
};
const cellIntialState = {
prevCell: null,
cell: null,
};
const updateCell = (cell: Cell) => {
cell.docRef.update({ [cell.fieldName]: cell.value });
};
const useCell = (intialOverrides: any) => {
const [cellState, cellDispatch] = useReducer(cellReducer, {
...cellIntialState,
...intialOverrides,
});
useEffect(() => {
const { prevCell, updatedValue } = cellState;
// check for change
if (
updatedValue !== null &&
updatedValue !== undefined &&
prevCell &&
prevCell.value !== updatedValue
) {
updateCell({ ...prevCell, value: updatedValue });
cellDispatch({ updatedValue: null });
}
}, [cellState.cell]);
const set = (cell: Cell | null) => {
cellDispatch({ prevCell: cellState.cell, cell });
};
const update = (value: any) => {
cellDispatch({ updatedValue: value });
};
const updateFirestore = (cell: Cell) => {
cellDispatch({ cell: null });
updateCell(cell);
};
const actions = { set, update, updateFirestore };
return [cellState, actions];
};
export default useCell;

View File

@@ -2,7 +2,6 @@ import { db } from "../../firebase";
import { useEffect, useReducer } from "react";
import equals from "ramda/es/equals";
import { Cell } from "./useCell";
import firebase from "firebase/app";
const CAP = 500;
@@ -11,7 +10,7 @@ const tableReducer = (prevState: any, newProps: any) => {
switch (newProps.type) {
case "more":
if (prevState.limit < prevState.cap)
// rows count hardcap
// rows count hardCap
return { ...prevState, limit: prevState.limit + 10 };
else return { ...prevState };
default:
@@ -21,7 +20,7 @@ const tableReducer = (prevState: any, newProps: any) => {
return { ...prevState, ...newProps };
}
};
const tableIntialState = {
const tableInitialState = {
rows: [],
prevFilters: null,
prevPath: null,
@@ -34,10 +33,10 @@ const tableIntialState = {
cap: CAP,
};
const useTable = (intialOverrides: any) => {
const useTable = (initialOverrides: any) => {
const [tableState, tableDispatch] = useReducer(tableReducer, {
...tableIntialState,
...intialOverrides,
...tableInitialState,
...initialOverrides,
});
const getRows = (
filters: {
@@ -144,6 +143,7 @@ const useTable = (intialOverrides: any) => {
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
});
};
const tableActions = { deleteRow, setTable, addRow };
return [tableState, tableActions];
};

View File

@@ -2,6 +2,7 @@ import { useEffect } from "react";
import useDoc, { DocActions } from "../useDoc";
import { FieldType } from "../../components/Fields";
import _camelCase from "lodash/camelCase";
const useTableConfig = (tablePath: string) => {
const [tableConfigState, documentDispatch] = useDoc({
path: `${tablePath}/_FIRETABLE_`,
@@ -11,17 +12,18 @@ const useTableConfig = (tablePath: string) => {
if (doc && columns !== doc.columns) {
documentDispatch({ columns: doc.columns });
}
}, [tableConfigState]);
}, [tableConfigState.doc]);
const setTable = (table: string) => {
documentDispatch({ path: `${table}/_FIRETABLE_`, columns: [], doc: null });
};
const add = (name: string, type: FieldType) => {
const add = (name: string, type: FieldType, data?: any) => {
//TODO: validation
const { columns } = tableConfigState;
const key = _camelCase(name);
documentDispatch({
action: DocActions.update,
data: { columns: [...columns, { name, key, type }] },
data: { columns: [...columns, { name, key, type, ...data }] },
});
};
const resize = (index: number, width: number) => {
@@ -29,12 +31,25 @@ const useTableConfig = (tablePath: string) => {
columns[index].width = width;
documentDispatch({ action: DocActions.update, data: { columns } });
};
const rename = () => {};
const update = (index: number, updatables: any) => {
const { columns } = tableConfigState;
updatables.forEach((updatable: any) => {
columns[index][updatable.field] = updatable.value;
});
documentDispatch({ action: DocActions.update, data: { columns } });
};
const remove = (index: number) => {
const { columns } = tableConfigState;
columns.splice(index, 1);
documentDispatch({ action: DocActions.update, data: { columns } });
};
const actions = {
update,
add,
resize,
rename,
setTable,
remove,
};
return [tableConfigState, actions];
};

View File

@@ -17,10 +17,17 @@ const useSettings = () => {
const createTable = (name: string, collection: string) => {
const { tables } = settingsState;
// updates the setting doc
documentDispatch({
action: DocActions.update,
data: { tables: [...tables, { name, collection }] },
});
if (tables) {
documentDispatch({
action: DocActions.update,
data: { tables: [...tables, { name, collection }] },
});
} else {
db.doc("_FIRETABLE_/settings").set(
{ tables: [{ name, collection }] },
{ merge: true }
);
}
//create the firetable collection doc with empty columns
db.collection(collection)
.doc("_FIRETABLE_")

View File

@@ -1,11 +1,9 @@
import React from "react";
import { maxWidth } from "@material-ui/system";
import {
makeStyles,
createStyles,
Card,
CardActions,
CardContent,
Button,
Typography,

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React from "react";
import Navigation from "../components/Navigation";
import Table from "../components/Table";

View File

@@ -36,7 +36,7 @@ const useStyles = makeStyles(() =>
// TODO: Create an interface for props
const TablesView = (props: any) => {
const [settings, createTable] = useSettings();
const [settings] = useSettings();
const tables = settings.tables;
const classes = useStyles();
const router = useRouter();

125
yarn.lock
View File

@@ -1586,6 +1586,18 @@
"@svgr/plugin-svgo" "^4.3.1"
loader-utils "^1.2.3"
"@types/algoliasearch-helper@*":
version "2.26.1"
resolved "https://registry.yarnpkg.com/@types/algoliasearch-helper/-/algoliasearch-helper-2.26.1.tgz#60cf377e7cb4bd9a55f7eba35182792763230a24"
integrity sha512-JN1wq/yLxxBcc6MeSe57F9Aqv8wL964L0nBOUTSQ5OECzWxaECuGYV06VnGKn/c+9AGB97RAgqx2PUbYflZNqA==
dependencies:
"@types/algoliasearch" "*"
"@types/algoliasearch@*", "@types/algoliasearch@^3.34.2":
version "3.34.2"
resolved "https://registry.yarnpkg.com/@types/algoliasearch/-/algoliasearch-3.34.2.tgz#f3daed26185a21b77632c28835e340147532f575"
integrity sha512-a+ztY3iL+Dpor7wYaF4CO6obUYcVEyXue1ppQklP1VCUP+VGZyzMcYiZodNs9DFV1HEOW5VCLTIqiZ4ikQpKzA==
"@types/babel__core@^7.1.0":
version "7.1.3"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.3.tgz#e441ea7df63cd080dfcd02ab199e6d16a735fc30"
@@ -1730,6 +1742,22 @@
dependencies:
"@types/react" "*"
"@types/react-instantsearch-core@*":
version "5.2.9"
resolved "https://registry.yarnpkg.com/@types/react-instantsearch-core/-/react-instantsearch-core-5.2.9.tgz#c8050dedefcb86ff01a0ff584c1801f5b165fe4a"
integrity sha512-tem7Vc8R1SZlt5hxW/xAJT3jUbfwBEg6ZYf9lWr8DtccFRZkzbC8dVhV9os3Fq73hqYXWImB/sS3/wfRqwdW2g==
dependencies:
"@types/algoliasearch-helper" "*"
"@types/react" "*"
"@types/react-instantsearch-dom@^5.2.6":
version "5.2.6"
resolved "https://registry.yarnpkg.com/@types/react-instantsearch-dom/-/react-instantsearch-dom-5.2.6.tgz#b47fc2c19b1aa8df06a95f67d01f5485abace8f0"
integrity sha512-gx7QBnL+rG50IUaIoj2SVwXT4O5HVOayQoEqiWF1RU1py43E+Wtlyi6WCdTEfEOz0sj6kWNEXAPMhfz+vjl9mQ==
dependencies:
"@types/react" "*"
"@types/react-instantsearch-core" "*"
"@types/react-router-dom@^4.3.5":
version "4.3.5"
resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-4.3.5.tgz#72f229967690c890d00f96e6b85e9ee5780db31f"
@@ -2100,6 +2128,11 @@ after@0.8.2:
resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
agentkeepalive@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-2.2.0.tgz#c5d1bd4b129008f1163f236f86e5faea2026e2ef"
integrity sha1-xdG9SxKQCPEWPyNvhuX66iAm4u8=
ajv-errors@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d"
@@ -2120,6 +2153,27 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.5.5:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
algoliasearch@^3.34.0:
version "3.34.0"
resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-3.34.0.tgz#02eb97bd6718e3a2c71121b9c3b655a35a4ba645"
integrity sha512-s8LDedkTWTAWR5uCWgJzGxDkCrqiej5iE4Tc2iCV+ONOO35i5qnVdieKg5gv2VDXBE7IP0YoqfAq/CC0V8PA+Q==
dependencies:
agentkeepalive "^2.2.0"
debug "^2.6.9"
envify "^4.0.0"
es6-promise "^4.1.0"
events "^1.1.0"
foreach "^2.0.5"
global "^4.3.2"
inherits "^2.0.1"
isarray "^2.0.1"
load-script "^1.0.0"
object-keys "^1.0.11"
querystring-es3 "^0.2.1"
reduce "^1.0.1"
semver "^5.1.0"
tunnel-agent "^0.6.0"
alphanum-sort@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
@@ -4334,6 +4388,11 @@ dom-storage@2.1.0:
resolved "https://registry.yarnpkg.com/dom-storage/-/dom-storage-2.1.0.tgz#00fb868bc9201357ea243c7bcfd3304c1e34ea39"
integrity sha512-g6RpyWXzl0RR6OTElHKBl7nwnK87GUyZMYC7JWsB/IA73vpqK2K6LT39x4VepLxlSsWBFrPVLnsSR5Jyty0+2Q==
dom-walk@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018"
integrity sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg=
domain-browser@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
@@ -4610,6 +4669,14 @@ entities@^2.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4"
integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==
envify@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/envify/-/envify-4.1.0.tgz#f39ad3db9d6801b4e6b478b61028d3f0b6819f7e"
integrity sha512-IKRVVoAYr4pIx4yIWNsz9mOsboxlNXiu7TNBnem/K/uTHdkyzXWDzHCK7UTolqBbgaBz0tQHsD3YNls0uIIjiw==
dependencies:
esprima "^4.0.0"
through "~2.3.4"
errno@^0.1.3, errno@~0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618"
@@ -4667,6 +4734,11 @@ es6-iterator@2.0.3, es6-iterator@~2.0.3:
es5-ext "^0.10.35"
es6-symbol "^3.1.1"
es6-promise@^4.1.0:
version "4.2.8"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
es6-symbol@^3.1.1, es6-symbol@~3.1.1:
version "3.1.2"
resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.2.tgz#859fdd34f32e905ff06d752e7171ddd4444a7ed1"
@@ -4927,6 +4999,11 @@ eventemitter3@^3.0.0:
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==
events@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=
events@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88"
@@ -5372,6 +5449,11 @@ for-own@^0.1.3:
dependencies:
for-in "^1.0.1"
foreach@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99"
integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k=
forever-agent@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
@@ -5633,6 +5715,14 @@ global-prefix@^3.0.0:
kind-of "^6.0.2"
which "^1.3.1"
global@^4.3.2:
version "4.4.0"
resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406"
integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==
dependencies:
min-document "^2.19.0"
process "^0.11.10"
globals@^11.1.0, globals@^11.7.0:
version "11.12.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
@@ -6598,6 +6688,11 @@ isarray@2.0.1:
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
isarray@^2.0.1:
version "2.0.5"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -7490,11 +7585,14 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
<<<<<<< HEAD
listenercount@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937"
integrity sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc=
=======
>>>>>>> 388c726eacf3bd64f948944e2771b63a8afddedc
load-json-file@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@@ -7526,6 +7624,11 @@ load-json-file@^4.0.0:
pify "^3.0.0"
strip-bom "^3.0.0"
load-script@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/load-script/-/load-script-1.0.0.tgz#0491939e0bee5643ee494a7e3da3d2bac70c6ca4"
integrity sha1-BJGTngvuVkPuSUp+PaPSuscMbKQ=
loader-fs-cache@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/loader-fs-cache/-/loader-fs-cache-1.0.2.tgz#54cedf6b727e1779fd8f01205f05f6e88706f086"
@@ -7871,6 +7974,13 @@ mimic-fn@^2.0.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
min-document@^2.19.0:
version "2.19.0"
resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685"
integrity sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=
dependencies:
dom-walk "^0.1.0"
mini-create-react-context@^0.3.0:
version "0.3.2"
resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz#79fc598f283dd623da8e088b05db8cddab250189"
@@ -8368,7 +8478,7 @@ object-is@^1.0.1:
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.1.tgz#0aa60ec9989a0b3ed795cf4d06f62cf1ad6539b6"
integrity sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=
object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.0, object-keys@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
@@ -9887,7 +9997,7 @@ qs@~6.5.2:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
querystring-es3@^0.2.0:
querystring-es3@^0.2.0, querystring-es3@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=
@@ -10363,6 +10473,13 @@ recursive-readdir@2.2.2:
dependencies:
minimatch "3.0.4"
reduce@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/reduce/-/reduce-1.0.2.tgz#0cd680ad3ffe0b060e57a5c68bdfce37168d361b"
integrity sha512-xX7Fxke/oHO5IfZSk77lvPa/7bjMh9BuCk4OOoX5XTXrM7s0Z+MkPfSDfz0q7r91BhhGSs8gii/VEN/7zhCPpQ==
dependencies:
object-keys "^1.1.0"
redux@^3.7.1:
version "3.7.2"
resolved "https://registry.yarnpkg.com/redux/-/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b"
@@ -10843,7 +10960,7 @@ semver-compare@^1.0.0:
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
integrity sha1-De4hahyUGrN+nvsXiPavxf9VN/w=
"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0:
"semver@2 || 3 || 4 || 5", semver@^5.1.0, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
@@ -11743,7 +11860,7 @@ through2@^2.0.0:
readable-stream "~2.3.6"
xtend "~4.0.1"
through@^2.3.6:
through@^2.3.6, through@~2.3.4:
version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=