diff --git a/functions/src/environmentDataViewer/index.js b/functions/src/environmentDataViewer/index.js new file mode 100644 index 00000000..51b85a76 --- /dev/null +++ b/functions/src/environmentDataViewer/index.js @@ -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) diff --git a/functions/test/unit/environmentDataViewer.spec.js b/functions/test/unit/environmentDataViewer.spec.js new file mode 100644 index 00000000..b078a472 --- /dev/null +++ b/functions/test/unit/environmentDataViewer.spec.js @@ -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') + }) +}) diff --git a/src/constants.js b/src/constants.js index 2c056fb1..8ee9e43c 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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' @@ -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, diff --git a/src/routes/ActionTemplate/components/ActionTemplateBackups/ActionTemplateBackups.js b/src/routes/ActionTemplate/components/ActionTemplateBackups/ActionTemplateBackups.js index 31a18dd3..c64df3a8 100644 --- a/src/routes/ActionTemplate/components/ActionTemplateBackups/ActionTemplateBackups.js +++ b/src/routes/ActionTemplate/components/ActionTemplateBackups/ActionTemplateBackups.js @@ -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 }) => ( -
- - {fields.map((member, index, field) => ( - - }> - - {fields.get(index).name || fields.get(index).type || 'No Name'} - - - - - - - - - - fields.remove(index)} - color="secondary" - className={classes.submit}> - - - - -
-
-

Source

- - Select Resource +function ActionTemplateBackups({ fields, steps }) { + return ( +
+ + {fields.map((member, index, field) => ( + + }> + + {fields.get(index).name || fields.get(index).type || 'No Name'} + + + + + + + + + + fields.remove(index)} + color="secondary" + className={classes.submit}> + + + + +
+
+

Source

+ + + Select Resource + + + {RESOURCE_OPTIONS.map((option, idx) => ( + + + + ))} + + - {resourcesOptions.map((option, idx) => ( - - - - ))} - - - + name={`${member}.inputs.0.path`} + component={TextField} + label="Path" + className={classes.field} + /> +
-
+ - - - - ))} -
-) + + + ))} +
+ ) +} ActionTemplateBackups.propTypes = { fields: PropTypes.object.isRequired, diff --git a/src/routes/Project/index.js b/src/routes/Project/index.js index 14a833cd..71f0427a 100644 --- a/src/routes/Project/index.js +++ b/src/routes/Project/index.js @@ -29,6 +29,7 @@ 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, [ @@ -36,6 +37,7 @@ export default store => ({ Environments(store), BucketConfig(store), ProjectEvents(store), + DataViewer(store), Permissions(store) ]) }) diff --git a/src/routes/Project/routes/DataViewer/components/DataViewerPage/DataViewerPage.enhancer.js b/src/routes/Project/routes/DataViewer/components/DataViewerPage/DataViewerPage.enhancer.js new file mode 100644 index 00000000..2de868d9 --- /dev/null +++ b/src/routes/Project/routes/DataViewer/components/DataViewerPage/DataViewerPage.enhancer.js @@ -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) +) diff --git a/src/routes/Project/routes/DataViewer/components/DataViewerPage/DataViewerPage.js b/src/routes/Project/routes/DataViewer/components/DataViewerPage/DataViewerPage.js new file mode 100644 index 00000000..32b9eb9d --- /dev/null +++ b/src/routes/Project/routes/DataViewer/components/DataViewerPage/DataViewerPage.js @@ -0,0 +1,19 @@ +import React from 'react' +import PropTypes from 'prop-types' +import DataViewerSetupForm from '../DataViewerSetupForm' + +function DataViewerPage({ classes, projectId, getData }) { + return ( +
+ +
+ ) +} + +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 diff --git a/src/routes/Project/routes/DataViewer/components/DataViewerPage/DataViewerPage.styles.js b/src/routes/Project/routes/DataViewer/components/DataViewerPage/DataViewerPage.styles.js new file mode 100644 index 00000000..ff2aa9eb --- /dev/null +++ b/src/routes/Project/routes/DataViewer/components/DataViewerPage/DataViewerPage.styles.js @@ -0,0 +1,5 @@ +export default theme => ({ + root: { + // style code + } +}) diff --git a/src/routes/Project/routes/DataViewer/components/DataViewerPage/index.js b/src/routes/Project/routes/DataViewer/components/DataViewerPage/index.js new file mode 100644 index 00000000..9b12ce6f --- /dev/null +++ b/src/routes/Project/routes/DataViewer/components/DataViewerPage/index.js @@ -0,0 +1,4 @@ +import DataViewerPage from './DataViewerPage' +import enhance from './DataViewerPage.enhancer' + +export default enhance(DataViewerPage) diff --git a/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/DataViewerSetupForm.enhancer.js b/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/DataViewerSetupForm.enhancer.js new file mode 100644 index 00000000..5d2c1947 --- /dev/null +++ b/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/DataViewerSetupForm.enhancer.js @@ -0,0 +1,16 @@ +import { get } from 'lodash' +import { compose } from 'redux' +import { reduxForm } from 'redux-form' +import { connect } from 'react-redux' +import { withStyles } from '@material-ui/core/styles' +import { DATA_VIEWER_SETUP_FORM } from 'constants' +import styles from './DataViewerSetupForm.styles' + +export default compose( + connect(({ firestore: { data, ordered } }, { projectId }) => ({ + project: get(data, `projects.${projectId}`), + environments: get(ordered, `environments-${projectId}`) + })), + reduxForm({ form: DATA_VIEWER_SETUP_FORM }), + withStyles(styles) +) diff --git a/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/DataViewerSetupForm.js b/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/DataViewerSetupForm.js new file mode 100644 index 00000000..49f29e88 --- /dev/null +++ b/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/DataViewerSetupForm.js @@ -0,0 +1,94 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Field } from 'redux-form' +import { map, capitalize } from 'lodash' +import { Select } from 'redux-form-material-ui' +import FormControl from '@material-ui/core/FormControl' +import InputLabel from '@material-ui/core/InputLabel' +import Button from '@material-ui/core/Button' +import MenuItem from '@material-ui/core/MenuItem' +import ListItemText from '@material-ui/core/ListItemText' +import OutlinedSelect from 'components/OutlinedSelect' +import { databaseURLToProjectName } from 'utils' +import { RESOURCE_OPTIONS } from 'constants' +import Grid from '@material-ui/core/Grid' + +function DataViewerSetupForm({ classes, environments, handleSubmit }) { + return ( +
+ + + + {map(environments, (environment, envIndex) => ( + + + + ))} + + + + + Select Resource + + {RESOURCE_OPTIONS.map((option, idx) => ( + + + + ))} + + + + + + + + + +
+ ) +} + +DataViewerSetupForm.propTypes = { + classes: PropTypes.object.isRequired, // from enhancer (withStyles) + handleSubmit: PropTypes.func.isRequired, // from enhancer (reduxForm) + environments: PropTypes.array.isRequired // from enhancer (reduxForm) +} + +export default DataViewerSetupForm diff --git a/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/DataViewerSetupForm.styles.js b/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/DataViewerSetupForm.styles.js new file mode 100644 index 00000000..ff2aa9eb --- /dev/null +++ b/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/DataViewerSetupForm.styles.js @@ -0,0 +1,5 @@ +export default theme => ({ + root: { + // style code + } +}) diff --git a/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/index.js b/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/index.js new file mode 100644 index 00000000..1d93de72 --- /dev/null +++ b/src/routes/Project/routes/DataViewer/components/DataViewerSetupForm/index.js @@ -0,0 +1,4 @@ +import DataViewerSetupForm from './DataViewerSetupForm' +import enhance from './DataViewerSetupForm.enhancer' + +export default enhance(DataViewerSetupForm) diff --git a/src/routes/Project/routes/DataViewer/index.js b/src/routes/Project/routes/DataViewer/index.js new file mode 100644 index 00000000..9644f441 --- /dev/null +++ b/src/routes/Project/routes/DataViewer/index.js @@ -0,0 +1,24 @@ +import { PROJECT_DATA_VIEWER_PATH as path } from 'constants' + +export default store => ({ + path, + /* Async getComponent is only invoked when route matches */ + getComponent(nextState, cb) { + /* Webpack - use 'require.ensure' to create a split point + and embed an async module loader (jsonp) when bundling */ + require.ensure( + [], + require => { + /* Webpack - use require callback to define + dependencies for bundling */ + const DataViewerPage = require('./components/DataViewerPage').default + + /* Return getComponent */ + cb(null, DataViewerPage) + + /* Webpack named bundle */ + }, + 'DataViewerPage' + ) + } +})