Skip to content

Commit

Permalink
Start Data viewer - #91
Browse files Browse the repository at this point in the history
  • Loading branch information
Scott Prue committed Sep 14, 2019
1 parent 2005068 commit 5ab94b2
Show file tree
Hide file tree
Showing 14 changed files with 401 additions and 83 deletions.
35 changes: 35 additions & 0 deletions functions/src/environmentDataViewer/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as functions from 'firebase-functions'
import { getAppFromServiceAccount } from '../utils/serviceAccounts'
import { dataArrayFromSnap } from '../utils/firestore'

/**
* @param {Object} data - Data passed into httpsCallable by client
* @param {Object} context - Cloud function context
* @param {Object} context.auth - Cloud function context
* @param {Object} context.auth.uid - UID of user that made the request
* @param {Object} context.auth.name - Name of user that made the request
*/
export async function environmentDataViewerRequest(data, context) {
console.log('Environment data viewer request:', data)
const { projectId } = data
// TODO: Confirm user has rights to this environment/serviceAccount
// Get app from service account (loaded from project)
const app = await getAppFromServiceAccount(data, { projectId })
// TODO: Make this dynamice to a number of resources
const topLevelResource = app[data.resource]() // database/firestore/etc
// TODO: Support multiple levels of query
const query =
data.resource === 'firestore'
? topLevelResource.collection('projects').get()
: topLevelResource.ref('projects').once('value')
const dataSnap = await query
const results = dataArrayFromSnap(dataSnap)
return results
}

/**
* @name environmentDataViewer
* Cloud Function triggered by HTTP request
* @type {functions.CloudFunction}
*/
export default functions.https.onCall(environmentDataViewerRequest)
46 changes: 46 additions & 0 deletions functions/test/unit/environmentDataViewer.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { to } from 'utils/async'

describe('environmentDataViewer HTTPS Callable Cloud Function', () => {
let environmentDataViewer
let configStub
let adminInitStub
let functions
let admin

before(() => {
/* eslint-disable global-require */
admin = require('firebase-admin')
// Stub Firebase's admin.initializeApp
adminInitStub = sinon.stub(admin, 'initializeApp')
// Stub Firebase's functions.config()
functions = require('firebase-functions')
configStub = sinon.stub(functions, 'config').returns({
firebase: {
databaseURL: 'https://not-a-project.firebaseio.com',
storageBucket: 'not-a-project.appspot.com',
projectId: 'not-a-project.appspot',
messagingSenderId: '823357791673'
}
// Stub any other config values needed by your functions here
})
environmentDataViewer = require(`./index`).environmentDataViewerRequest
/* eslint-enable global-require */
})

after(() => {
// Restoring our stubs to the original methods.
configStub.restore()
adminInitStub.restore()
})

it('responds with hello message when sent an empty request', async () => {
const data = {}
const context = {}
// Invoke request handler with fake data + context objects
const [err, response] = await to(environmentDataViewer(data, context))
// Confirm no error is thrown
expect(err).to.not.exist
// Confirm response contains message
expect(response).to.have.property('message', 'Hello World')
})
})
8 changes: 8 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const NEW_ACTION_TEMPLATE_PATH = '/actions'
export const PROJECT_ACTION_PATH = 'actions'
export const PROJECT_ENVIRONMENTS_PATH = 'environments'
export const PROJECT_EVENTS_PATH = 'events'
export const PROJECT_DATA_VIEWER_PATH = 'data-viewer'
export const DATA_VIEWER_SETUP_FORM = 'dataViewerSetup'
export const ACCOUNT_FORM_NAME = 'account'
export const LOGIN_FORM_NAME = 'login'
export const SIGNUP_FORM_NAME = 'signup'
Expand Down Expand Up @@ -42,6 +44,12 @@ export const ANALYTICS_EVENT_NAMES = {
deleteRole: 'Delete Role'
}

export const RESOURCE_OPTIONS = [
{ value: 'rtdb', label: 'Real Time Database' },
{ value: 'firestore' },
{ value: 'storage', label: 'Cloud Storage' }
]

export const formNames = {
account: ACCOUNT_FORM_NAME,
signup: SIGNUP_FORM_NAME,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,95 +17,95 @@ import Grid from '@material-ui/core/Grid'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
import DeleteIcon from '@material-ui/icons/Delete'
import classes from './ActionTemplateBackups.scss'
import { RESOURCE_OPTIONS } from 'constants'

// const pathTypeOptions = [{ value: 'only' }, { value: 'all but' }]
const resourcesOptions = [
{ value: 'rtdb', label: 'Real Time Database' },
{ value: 'firestore' },
{ value: 'storage', label: 'Cloud Storage' }
]

export const ActionTemplateBackups = ({ fields, steps }) => (
<div>
<Button
onClick={() => fields.push({ dest: { resource: 'firestore' } })}
color="primary"
className={classes.addAction}
variant="contained">
Add Backup
</Button>
{fields.map((member, index, field) => (
<ExpansionPanel key={index}>
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
<Typography className={classes.title}>
{fields.get(index).name || fields.get(index).type || 'No Name'}
</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails>
<Grid container spacing={24} style={{ flexGrow: 1 }}>
<Grid item xs={12} lg={6}>
<Field
name={`${member}.name`}
component={TextField}
label="Name"
className={classes.field}
/>
<Field
name={`${member}.description`}
component={TextField}
label="Description"
className={classes.field}
/>
</Grid>
<Grid item xs={12} lg={6}>
<IconButton
onClick={() => fields.remove(index)}
color="secondary"
className={classes.submit}>
<DeleteIcon />
</IconButton>
</Grid>
<Grid item xs={12} lg={6}>
<div className={classes.sections}>
<div className="flex-column">
<h4>Source</h4>
<FormControl className={classes.field}>
<InputLabel htmlFor="resource">Select Resource</InputLabel>
function ActionTemplateBackups({ fields, steps }) {
return (
<div>
<Button
onClick={() => fields.push({ dest: { resource: 'firestore' } })}
color="primary"
className={classes.addAction}
variant="contained">
Add Backup
</Button>
{fields.map((member, index, field) => (
<ExpansionPanel key={index}>
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
<Typography className={classes.title}>
{fields.get(index).name || fields.get(index).type || 'No Name'}
</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails>
<Grid container spacing={24} style={{ flexGrow: 1 }}>
<Grid item xs={12} lg={6}>
<Field
name={`${member}.name`}
component={TextField}
label="Name"
className={classes.field}
/>
<Field
name={`${member}.description`}
component={TextField}
label="Description"
className={classes.field}
/>
</Grid>
<Grid item xs={12} lg={6}>
<IconButton
onClick={() => fields.remove(index)}
color="secondary"
className={classes.submit}>
<DeleteIcon />
</IconButton>
</Grid>
<Grid item xs={12} lg={6}>
<div className={classes.sections}>
<div className="flex-column">
<h4>Source</h4>
<FormControl className={classes.field}>
<InputLabel htmlFor="resource">
Select Resource
</InputLabel>
<Field
name={`${member}.inputs.0.resource`}
component={Select}
fullWidth
inputProps={{
name: 'resource',
id: 'resource'
}}>
{RESOURCE_OPTIONS.map((option, idx) => (
<MenuItem
key={`Option-${option.value}-${idx}`}
value={option.value}
disabled={option.disabled}>
<ListItemText
primary={option.label || capitalize(option.value)}
/>
</MenuItem>
))}
</Field>
</FormControl>
<Field
name={`${member}.inputs.0.resource`}
component={Select}
fullWidth
inputProps={{
name: 'resource',
id: 'resource'
}}>
{resourcesOptions.map((option, idx) => (
<MenuItem
key={`Option-${option.value}-${idx}`}
value={option.value}
disabled={option.disabled}>
<ListItemText
primary={option.label || capitalize(option.value)}
/>
</MenuItem>
))}
</Field>
</FormControl>
<Field
name={`${member}.inputs.0.path`}
component={TextField}
label="Path"
className={classes.field}
/>
name={`${member}.inputs.0.path`}
component={TextField}
label="Path"
className={classes.field}
/>
</div>
</div>
</div>
</Grid>
</Grid>
</Grid>
</ExpansionPanelDetails>
</ExpansionPanel>
))}
</div>
)
</ExpansionPanelDetails>
</ExpansionPanel>
))}
</div>
)
}

ActionTemplateBackups.propTypes = {
fields: PropTypes.object.isRequired,
Expand Down
2 changes: 2 additions & 0 deletions src/routes/Project/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ export default store => ({
const Actions = require('./routes/Actions').default
const BucketConfig = require('./routes/BucketConfig').default
const Permissions = require('./routes/Permissions').default
const DataViewer = require('./routes/DataViewer').default

/* Return getComponent */
cb(null, [
Actions(store),
Environments(store),
BucketConfig(store),
ProjectEvents(store),
DataViewer(store),
Permissions(store)
])
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import PropTypes from 'prop-types'
import { get } from 'lodash'
import { compose } from 'redux'
import { connect } from 'react-redux'
import firestoreConnect from 'react-redux-firebase/lib/firestoreConnect'
import { withStyles } from '@material-ui/core/styles'
import styles from './DataViewerPage.styles'
// import { formValueSelector } from 'redux-form'
// import { formNames } from 'constants'
import { withHandlers, setPropTypes } from 'recompose'
import firebase from 'firebase/app'
import withNotifications from 'modules/notification/components/withNotifications'

export default compose(
withNotifications,
// Proptypes for props used in HOCs
setPropTypes({
params: PropTypes.shape({
projectId: PropTypes.string.isRequired
})
}),
// create listener for dataViewer, results go into redux
firestoreConnect([{ collection: 'dataViewer' }]),
// map redux state to props
// Map redux state to props
connect((state, { params }) => {
const {
firebase,
firestore: { data, ordered }
} = state
// const formSelector = formValueSelector(formNames.actionRunner)
const environmentsById = get(data, `environments-${params.projectId}`)
return {
uid: firebase.auth.uid,
projectId: params.projectId,
project: get(data, `projects.${params.projectId}`),
environments: get(ordered, `environments-${params.projectId}`),
environmentsById
}
}),
withHandlers({
getData: ({ showSuccess, showError, projectId }) => formData => {
return firebase
.functions()
.httpsCallable('environmentDataViewer')({ projectId, ...formData })
.then(() => {
showSuccess('Data loaded')
})
.catch(err => {
showError('Error loading data')
return Promise.reject(err)
})
}
}),
withStyles(styles)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react'
import PropTypes from 'prop-types'
import DataViewerSetupForm from '../DataViewerSetupForm'

function DataViewerPage({ classes, projectId, getData }) {
return (
<div className={classes.container}>
<DataViewerSetupForm projectId={projectId} onSubmit={getData} />
</div>
)
}

DataViewerPage.propTypes = {
classes: PropTypes.object.isRequired, // from enhancer (withStyles)
getData: PropTypes.func.isRequired, // from enhancer (withHandlers)
projectId: PropTypes.string.isRequired // from react-router
}

export default DataViewerPage
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default theme => ({
root: {
// style code
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import DataViewerPage from './DataViewerPage'
import enhance from './DataViewerPage.enhancer'

export default enhance(DataViewerPage)
Loading

0 comments on commit 5ab94b2

Please sign in to comment.