diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b1dee0b0d..60975be7fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: - run: npm run lint check-circular: name: Circular Dependencies - timeout-minutes: 5 + timeout-minutes: 15 runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/Parse-Dashboard/Authentication.js b/Parse-Dashboard/Authentication.js index 0a6beee442..53f2b05b17 100644 --- a/Parse-Dashboard/Authentication.js +++ b/Parse-Dashboard/Authentication.js @@ -54,25 +54,30 @@ function initialize(app, options) { }); var cookieSessionSecret = options.cookieSessionSecret || require('crypto').randomBytes(64).toString('hex'); + const cookieSessionMaxAge = options.cookieSessionMaxAge; app.use(require('connect-flash')()); app.use(require('body-parser').urlencoded({ extended: true })); app.use(require('cookie-session')({ key : 'parse_dash', secret : cookieSessionSecret, - cookie : { - maxAge: (2 * 7 * 24 * 60 * 60 * 1000) // 2 weeks - } + maxAge : cookieSessionMaxAge })); app.use(passport.initialize()); app.use(passport.session()); app.post('/login', csrf(), - passport.authenticate('local', { - successRedirect: `${self.mountPath}apps`, - failureRedirect: `${self.mountPath}login`, - failureFlash : true - }) + (req,res,next) => { + let redirect = 'apps'; + if (req.body.redirect) { + redirect = req.body.redirect.charAt(0) === '/' ? req.body.redirect.substring(1) : req.body.redirect + } + return passport.authenticate('local', { + successRedirect: `${self.mountPath}${redirect}`, + failureRedirect: `${self.mountPath}login${req.body.redirect ? `?redirect=${req.body.redirect}` : ''}`, + failureFlash : true + })(req, res, next) + }, ); app.get('/logout', function(req, res){ diff --git a/Parse-Dashboard/app.js b/Parse-Dashboard/app.js index 0149b8c634..56ac3146f0 100644 --- a/Parse-Dashboard/app.js +++ b/Parse-Dashboard/app.js @@ -68,7 +68,7 @@ module.exports = function(config, options) { const users = config.users; const useEncryptedPasswords = config.useEncryptedPasswords ? true : false; const authInstance = new Authentication(users, useEncryptedPasswords, mountPath); - authInstance.initialize(app, { cookieSessionSecret: options.cookieSessionSecret }); + authInstance.initialize(app, { cookieSessionSecret: options.cookieSessionSecret, cookieSessionMaxAge: options.cookieSessionMaxAge }); // CSRF error handler app.use(function (err, req, res, next) { @@ -173,8 +173,9 @@ module.exports = function(config, options) { } app.get('/login', csrf(), function(req, res) { + const redirectURL = req.url.includes('?redirect=') && req.url.split('?redirect=')[1]; if (!users || (req.user && req.user.isAuthenticated)) { - return res.redirect(`${mountPath}apps`); + return res.redirect(`${mountPath}${redirectURL || 'apps'}`); } let errors = req.flash('error'); @@ -206,7 +207,7 @@ module.exports = function(config, options) { // For every other request, go to index.html. Let client-side handle the rest. app.get('/*', function(req, res) { if (users && (!req.user || !req.user.isAuthenticated)) { - return res.redirect(`${mountPath}login`); + return res.redirect(`${mountPath}login?redirect=${req.url.replace('/login', '')}`); } if (users && req.user && req.user.matchingUsername ) { res.append('username', req.user.matchingUsername); diff --git a/Parse-Dashboard/index.js b/Parse-Dashboard/index.js index d4694d7a42..6217df3e95 100644 --- a/Parse-Dashboard/index.js +++ b/Parse-Dashboard/index.js @@ -28,6 +28,8 @@ program.option('--trustProxy [trustProxy]', 'set this flag when you are behind a program.option('--cookieSessionSecret [cookieSessionSecret]', 'set the cookie session secret, defaults to a random string. You should set that value if you want sessions to work across multiple server, or across restarts'); program.option('--createUser', 'helper tool to allow you to generate secure user passwords and secrets. Use this on trusted devices only.'); program.option('--createMFA', 'helper tool to allow you to generate multi-factor authentication secrets.'); +program.option('--cookieSessionMaxAge [cookieSessionMaxAge]', '(Optional) Sets the time in seconds for when the session cookie will be deleted and the dashboard user has to re-login; if no value is set then the cookie will be deleted when the browser session ends.'); + program.action(async (options) => { for (const key in options) { const func = CLIHelper[key]; diff --git a/Parse-Dashboard/server.js b/Parse-Dashboard/server.js index 2d21d8a6ba..76ac4bc398 100644 --- a/Parse-Dashboard/server.js +++ b/Parse-Dashboard/server.js @@ -19,6 +19,7 @@ module.exports = (options) => { const allowInsecureHTTP = options.allowInsecureHTTP || process.env.PARSE_DASHBOARD_ALLOW_INSECURE_HTTP; const cookieSessionSecret = options.cookieSessionSecret || process.env.PARSE_DASHBOARD_COOKIE_SESSION_SECRET; const trustProxy = options.trustProxy || process.env.PARSE_DASHBOARD_TRUST_PROXY; + const cookieSessionMaxAge = options.cookieSessionMaxAge || process.env.PARSE_DASHBOARD_COOKIE_SESSION_MAX_AGE; const dev = options.dev; if (trustProxy && allowInsecureHTTP) { @@ -145,7 +146,7 @@ module.exports = (options) => { if (allowInsecureHTTP || trustProxy || dev) app.enable('trust proxy'); config.data.trustProxy = trustProxy; - let dashboardOptions = { allowInsecureHTTP, cookieSessionSecret, dev }; + let dashboardOptions = { allowInsecureHTTP, cookieSessionSecret, dev, cookieSessionMaxAge }; app.use(mountPath, parseDashboard(config.data, dashboardOptions)); let server; if(!configSSLKey || !configSSLCert){ diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 011fc1f9f2..7249ba15e1 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,38 @@ +# [5.1.0-alpha.6](https://github.com/ParsePlatform/parse-dashboard/compare/5.1.0-alpha.5...5.1.0-alpha.6) (2023-01-25) + + +### Bug Fixes + +* Navigation to page fails if user re-login is required ([#2369](https://github.com/ParsePlatform/parse-dashboard/issues/2369)) ([0db6f55](https://github.com/ParsePlatform/parse-dashboard/commit/0db6f5559f9b7bb1f5a282c6182810ca89945032)) + +# [5.1.0-alpha.5](https://github.com/ParsePlatform/parse-dashboard/compare/5.1.0-alpha.4...5.1.0-alpha.5) (2023-01-25) + + +### Features + +* Add export all rows of a class and export in JSON format ([#2361](https://github.com/ParsePlatform/parse-dashboard/issues/2361)) ([9eb36a1](https://github.com/ParsePlatform/parse-dashboard/commit/9eb36a183b8b337960f6e8563ad686958001a22b)) + +# [5.1.0-alpha.4](https://github.com/ParsePlatform/parse-dashboard/compare/5.1.0-alpha.3...5.1.0-alpha.4) (2023-01-25) + + +### Bug Fixes + +* Add dashboard option `cookieSessionMaxAge` to keep user logged in across browser sessions ([#2366](https://github.com/ParsePlatform/parse-dashboard/issues/2366)) ([9ea95fc](https://github.com/ParsePlatform/parse-dashboard/commit/9ea95fc62103b52cf4fac1d1b567334b5298b318)) + +# [5.1.0-alpha.3](https://github.com/ParsePlatform/parse-dashboard/compare/5.1.0-alpha.2...5.1.0-alpha.3) (2023-01-20) + + +### Features + +* Add schema export ([#2362](https://github.com/ParsePlatform/parse-dashboard/issues/2362)) ([33df049](https://github.com/ParsePlatform/parse-dashboard/commit/33df0495a02c4e77f48b3566032bf5686227cce7)) + +# [5.1.0-alpha.2](https://github.com/ParsePlatform/parse-dashboard/compare/5.1.0-alpha.1...5.1.0-alpha.2) (2023-01-20) + + +### Bug Fixes + +* Blank screen shown if server is unreachable; unsupported pages are accessible via direct URLs ([#2363](https://github.com/ParsePlatform/parse-dashboard/issues/2363)) ([9855258](https://github.com/ParsePlatform/parse-dashboard/commit/98552584df4d8d75d65d3e394b4acad522117a96)) + # [5.1.0-alpha.1](https://github.com/ParsePlatform/parse-dashboard/compare/5.0.0...5.1.0-alpha.1) (2022-11-05) diff --git a/docker-compose.uffizzi.yml b/docker-compose.uffizzi.yml index deca90a6fc..43968506bd 100644 --- a/docker-compose.uffizzi.yml +++ b/docker-compose.uffizzi.yml @@ -9,6 +9,7 @@ x-uffizzi: deploy_preview_when_pull_request_is_opened: true delete_preview_when_pull_request_is_closed: true share_to_github: true + services: postgres: @@ -59,7 +60,6 @@ services: command: - "-c" - "PARSE_DASHBOARD_SERVER_URL=$$UFFIZZI_URL/parse node Parse-Dashboard/index.js" - #- PARSE_DASHBOARD_COOKIE_SESSION_SECRET=AB8849B6-D725-4A75-AA73-AB7103F0363F deploy: resources: limits: diff --git a/nginx-uffizzi/html/index.html b/nginx-uffizzi/html/index.html index 1f624f6a33..116a29ae7b 100644 --- a/nginx-uffizzi/html/index.html +++ b/nginx-uffizzi/html/index.html @@ -14,7 +14,6 @@

Endpoint:

Click to Visit Parse Dashboard - diff --git a/package-lock.json b/package-lock.json index dcaf4c20fd..e3b64f3930 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "parse-dashboard", - "version": "5.1.0-alpha.1", + "version": "5.1.0-alpha.6", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -244,12 +244,6 @@ "integrity": "sha512-saJTYECxUSv7eSpnXw0XIEvUkP9x4s/x2mm3TVX7k4rIFS6f5TjBih1B5h437WzIhHQjid+d8ouQzPQskMervQ==", "dev": true }, - "json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", - "dev": true - }, "node-releases": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", @@ -11814,13 +11808,10 @@ "dev": true }, "json5": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", - "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==", - "dev": true, - "requires": { - "minimist": "^1.2.5" - } + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true }, "jsonfile": { "version": "6.1.0", @@ -18436,9 +18427,9 @@ "integrity": "sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==" }, "ua-parser-js": { - "version": "0.7.28", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.28.tgz", - "integrity": "sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==" + "version": "0.7.33", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz", + "integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw==" }, "uc.micro": { "version": "1.0.6", diff --git a/package.json b/package.json index 0df380e9b6..7cb8027346 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-dashboard", - "version": "5.1.0-alpha.1", + "version": "5.1.0-alpha.6", "repository": { "type": "git", "url": "https://github.com/ParsePlatform/parse-dashboard" diff --git a/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js index 3a639cdb79..9993dc0722 100644 --- a/src/dashboard/Dashboard.js +++ b/src/dashboard/Dashboard.js @@ -138,21 +138,21 @@ export default class Dashboard extends React.Component { if (error.code === 100) { app.serverInfo = { error: 'unable to connect to server', - enabledFeatures: {}, + features: {}, parseServerVersion: 'unknown' } return Promise.resolve(app); } else if (error.code === 107) { app.serverInfo = { error: 'server version too low', - enabledFeatures: {}, + features: {}, parseServerVersion: 'unknown' } return Promise.resolve(app); } else { app.serverInfo = { error: error.message || 'unknown error', - enabledFeatures: {}, + features: {}, parseServerVersion: 'unknown' } return Promise.resolve(app); diff --git a/src/dashboard/Dashboard.scss b/src/dashboard/Dashboard.scss index 140c11b2bd..c99ad86341 100644 --- a/src/dashboard/Dashboard.scss +++ b/src/dashboard/Dashboard.scss @@ -17,4 +17,25 @@ body:global(.expanded) { .content { margin-left: $sidebarCollapsedWidth; } +} + +.loadingError { + font-size: 58px; + color: #ffffff; +} + +.empty { + position: relative; + background: #1e3b4d; + min-height: 100vh; + text-align: center; +} + +.cloud { + width: 170px; + height: 170px; + border-radius: 100%; + padding-top: 30px; + background: #3E5566; + margin: 0 auto 14px auto; } \ No newline at end of file diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js index 15972fd8e6..4846ae4c72 100644 --- a/src/dashboard/DashboardView.react.js +++ b/src/dashboard/DashboardView.react.js @@ -8,82 +8,125 @@ import React from 'react'; import Sidebar from 'components/Sidebar/Sidebar.react'; import styles from 'dashboard/Dashboard.scss'; +import Icon from 'components/Icon/Icon.react'; +import baseStyles from 'stylesheets/base.scss'; +import Button from 'components/Button/Button.react'; import { CurrentApp } from 'context/currentApp'; export default class DashboardView extends React.Component { static contextType = CurrentApp; - /* A DashboardView renders two pieces: the sidebar, and the app itself */ + + constructor() { + super(); + this.state = { + route: '', + }; + } + + componentDidUpdate() { + this.onRouteChanged(); + } + componentDidMount() { + this.onRouteChanged(); + } + + onRouteChanged() { + const appId = this.context.applicationId; + const path = this.props.location?.pathname ?? window.location.pathname; + const route = path.split(appId)[1].split('/')[1]; + if (route !== this.state.route) { + this.setState({ route }); + } + } + render() { let sidebarChildren = null; if (typeof this.renderSidebar === 'function') { sidebarChildren = this.renderSidebar(); } - let appSlug = (this.context ? this.context.slug : ''); + let appSlug = this.context ? this.context.slug : ''; if (!this.context.hasCheckedForMigraton) { - this.context.getMigrations().promise - .then(() => this.forceUpdate(), () => {}); + this.context.getMigrations().promise.then( + () => this.forceUpdate(), + () => {} + ); } let features = this.context.serverInfo.features; let coreSubsections = []; - if (features.schemas && + if ( + features.schemas && features.schemas.addField && features.schemas.removeField && features.schemas.addClass && - features.schemas.removeClass) { + features.schemas.removeClass + ) { coreSubsections.push({ name: 'Browser', - link: '/browser' + link: '/browser', }); } if (features.cloudCode && features.cloudCode.viewCode) { coreSubsections.push({ name: 'Cloud Code', - link: '/cloud_code' + link: '/cloud_code', }); } //webhooks requires removal of heroku link code, then it should work. - if (features.hooks && features.hooks.create && features.hooks.read && features.hooks.update && features.hooks.delete) { + if ( + features.hooks && + features.hooks.create && + features.hooks.read && + features.hooks.update && + features.hooks.delete + ) { coreSubsections.push({ name: 'Webhooks', - link: '/webhooks' + link: '/webhooks', }); } if (features.cloudCode && features.cloudCode.jobs) { coreSubsections.push({ name: 'Jobs', - link: '/jobs' + link: '/jobs', }); } - if (features.logs && Object.keys(features.logs).some(key => features.logs[key])) { + if ( + features.logs && + Object.keys(features.logs).some((key) => features.logs[key]) + ) { coreSubsections.push({ name: 'Logs', - link: '/logs' + link: '/logs', }); } - if (features.globalConfig && + if ( + features.globalConfig && features.globalConfig.create && features.globalConfig.read && features.globalConfig.update && - features.globalConfig.delete) { + features.globalConfig.delete + ) { coreSubsections.push({ name: 'Config', - link: '/config' + link: '/config', }); } - coreSubsections.push({ - name: 'API Console', - link: '/api_console' - }); + if (!this.context.serverInfo.error) { + coreSubsections.push({ + name: 'API Console', + link: '/api_console', + }); + } if (this.context.migration) { coreSubsections.push({ @@ -96,21 +139,21 @@ export default class DashboardView extends React.Component { if (features.push && features.push.immediatePush) { pushSubsections.push({ name: 'Send New Push', - link: '/push/new' + link: '/push/new', }); } if (features.push && features.push.storedPushData) { pushSubsections.push({ name: 'Past Pushes', - link: '/push/activity' + link: '/push/activity', }); } if (features.push && features.push.pushAudiences) { pushSubsections.push({ name: 'Audiences', - link: '/push/audiences' + link: '/push/audiences', }); } @@ -195,7 +238,7 @@ export default class DashboardView extends React.Component { }); }*/ - let appSidebarSections = [] + let appSidebarSections = []; if (coreSubsections.length > 0) { appSidebarSections.push({ @@ -211,7 +254,7 @@ export default class DashboardView extends React.Component { name: 'Push', icon: 'push-outline', link: '/push', - style: {paddingLeft: '16px'}, + style: { paddingLeft: '16px' }, subsections: pushSubsections, }); } @@ -221,7 +264,7 @@ export default class DashboardView extends React.Component { name: 'Analytics', icon: 'analytics-outline', link: '/analytics', - subsections: analyticsSidebarSections + subsections: analyticsSidebarSections, }); } @@ -230,29 +273,77 @@ export default class DashboardView extends React.Component { name: 'App Settings', icon: 'gear-solid', link: '/settings', - subsections: settingsSections + subsections: settingsSections, }); } let sidebar = ( - - {sidebarChildren} - ); + {sidebarChildren} + + ); + + let content =
{this.renderContent()}
; + const canRoute = [...coreSubsections, ...pushSubsections] + .map(({ link }) => link.split('/')[1]) + .includes(this.state.route); + + if (!canRoute) { + content = ( +
+
+
+ +
+
Feature unavailable
+
+
+ ); + } + + if (this.context.serverInfo.error) { + content = ( +
+
+
+ +
+
+ {this.context.serverInfo.error.replace(/-/g, '\u2011')} +
+
+
+ ); + } return (
-
- {this.renderContent()} -
+ {content} {sidebar}
); diff --git a/src/dashboard/Data/Browser/Browser.react.js b/src/dashboard/Data/Browser/Browser.react.js index 8e35357351..46e18e8947 100644 --- a/src/dashboard/Data/Browser/Browser.react.js +++ b/src/dashboard/Data/Browser/Browser.react.js @@ -21,6 +21,7 @@ import AttachSelectedRowsDialog from 'dashboard/Data/Browser/AttachSel import CloneSelectedRowsDialog from 'dashboard/Data/Browser/CloneSelectedRowsDialog.react'; import EditRowDialog from 'dashboard/Data/Browser/EditRowDialog.react'; import ExportSelectedRowsDialog from 'dashboard/Data/Browser/ExportSelectedRowsDialog.react'; +import ExportSchemaDialog from 'dashboard/Data/Browser/ExportSchemaDialog.react'; import { List, Map } from 'immutable'; import Notification from 'dashboard/Data/Browser/Notification.react'; import Parse from 'parse'; @@ -56,6 +57,7 @@ class Browser extends DashboardView { showRemoveColumnDialog: false, showDropClassDialog: false, showExportDialog: false, + showExportSchemaDialog: false, showAttachRowsDialog: false, showEditRowDialog: false, showPointerKeyDialog: false, @@ -70,6 +72,8 @@ class Browser extends DashboardView { filters: new List(), ordering: '-createdAt', selection: {}, + exporting: false, + exportingCount: 0, data: null, lastMax: -1, @@ -88,7 +92,7 @@ class Browser extends DashboardView { requiredColumnFields: [], useMasterKey: true, - currentUser: Parse.User.current() + currentUser: Parse.User.current(), }; this.prefetchData = this.prefetchData.bind(this); @@ -114,6 +118,7 @@ class Browser extends DashboardView { this.confirmCloneSelectedRows = this.confirmCloneSelectedRows.bind(this); this.cancelCloneSelectedRows = this.cancelCloneSelectedRows.bind(this); this.showExportSelectedRowsDialog = this.showExportSelectedRowsDialog.bind(this); + this.showExportSchemaDialog = this.showExportSchemaDialog.bind(this); this.confirmExportSelectedRows = this.confirmExportSelectedRows.bind(this); this.cancelExportSelectedRows = this.cancelExportSelectedRows.bind(this); this.getClassRelationColumns = this.getClassRelationColumns.bind(this); @@ -326,6 +331,37 @@ class Browser extends DashboardView { }); } + async exportSchema(className, all) { + try { + this.showNote('Exporting schema...'); + this.setState({ showExportSchemaDialog: false }); + let schema = []; + if (all) { + schema = await Parse.Schema.all(); + } else { + schema = await new Parse.Schema(className).get(); + } + const element = document.createElement('a'); + const file = new Blob( + [ + JSON.stringify( + schema, + null, + 2, + ), + ], + { type: 'application/json' } + ); + element.href = URL.createObjectURL(file); + element.download = `${all ? 'schema' : className}.json`; + document.body.appendChild(element); // Required for this to work in FireFox + element.click(); + document.body.removeChild(element); + } catch (msg) { + this.showNote(msg, true); + } + } + newColumn(payload, required) { return this.props.schema.dispatch(ActionTypes.ADD_COLUMN, payload) .then(() => { @@ -1089,6 +1125,7 @@ class Browser extends DashboardView { this.state.showRemoveColumnDialog || this.state.showDropClassDialog || this.state.showExportDialog || + this.state.showExportSchema || this.state.rowsToDelete || this.state.showAttachRowsDialog || this.state.showAttachSelectedRowsDialog || @@ -1249,21 +1286,24 @@ class Browser extends DashboardView { }); } + showExportSchemaDialog() { + this.setState({ + showExportSchemaDialog: true + }) + } + cancelExportSelectedRows() { this.setState({ rowsToExport: null }); } - async confirmExportSelectedRows(rows) { - this.setState({ rowsToExport: null }); + async confirmExportSelectedRows(rows, type, indentation) { + this.setState({ rowsToExport: null, exporting: true, exportingCount: 0 }); const className = this.props.params.className; const query = new Parse.Query(className); - if (rows['*']) { - // Export all - query.limit(10000); - } else { + if (!rows['*']) { // Export selected const objectIds = []; for (const objectId in this.state.rowsToExport) { @@ -1273,75 +1313,136 @@ class Browser extends DashboardView { query.limit(objectIds.length); } - const classColumns = this.getClassColumns(className, false); - // create object with classColumns as property keys needed for ColumnPreferences.getOrder function - const columnsObject = {}; - classColumns.forEach((column) => { - columnsObject[column.name] = column; - }); - // get ordered list of class columns - const columns = ColumnPreferences.getOrder( - columnsObject, - this.context.applicationId, - className - ).filter(column => column.visible); + const processObjects = (objects) => { + const classColumns = this.getClassColumns(className, false); + // create object with classColumns as property keys needed for ColumnPreferences.getOrder function + const columnsObject = {}; + classColumns.forEach((column) => { + columnsObject[column.name] = column; + }); + // get ordered list of class columns + const columns = ColumnPreferences.getOrder( + columnsObject, + this.context.applicationId, + className + ).filter((column) => column.visible); + + if (type === '.json') { + const element = document.createElement('a'); + const file = new Blob( + [ + JSON.stringify( + objects.map((obj) => { + const json = obj._toFullJSON(); + delete json.__type; + return json; + }), + null, + indentation ? 2 : null, + ), + ], + { type: 'application/json' } + ); + element.href = URL.createObjectURL(file); + element.download = `${className}.json`; + document.body.appendChild(element); // Required for this to work in FireFox + element.click(); + document.body.removeChild(element); + return; + } - const objects = await query.find({ useMasterKey: true }); - let csvString = columns.map(column => column.name).join(',') + '\n'; - for (const object of objects) { - const row = columns.map(column => { - const type = columnsObject[column.name].type; - if (column.name === 'objectId') { - return object.id; - } else if (type === 'Relation' || type === 'Pointer') { - if (object.get(column.name)) { - return object.get(column.name).id - } else { - return '' - } - } else { - let colValue; - if (column.name === 'ACL') { - colValue = object.getACL(); - } else { - colValue = object.get(column.name); - } - // Stringify objects and arrays - if (Object.prototype.toString.call(colValue) === '[object Object]' || Object.prototype.toString.call(colValue) === '[object Array]') { - colValue = JSON.stringify(colValue); - } - if(typeof colValue === 'string') { - if (colValue.includes('"')) { - // Has quote in data, escape and quote - // If the value contains both a quote and delimiter, adding quotes and escaping will take care of both scenarios - colValue = colValue.split('"').join('""'); - return `"${colValue}"`; - } else if (colValue.includes(',')) { - // Has delimiter in data, surround with quote (which the value doesn't already contain) - return `"${colValue}"`; + let csvString = columns.map((column) => column.name).join(',') + '\n'; + for (const object of objects) { + const row = columns + .map((column) => { + const type = columnsObject[column.name].type; + if (column.name === 'objectId') { + return object.id; + } else if (type === 'Relation' || type === 'Pointer') { + if (object.get(column.name)) { + return object.get(column.name).id; + } else { + return ''; + } } else { - // No quote or delimiter, just include plainly - return `${colValue}`; + let colValue; + if (column.name === 'ACL') { + colValue = object.getACL(); + } else { + colValue = object.get(column.name); + } + // Stringify objects and arrays + if ( + Object.prototype.toString.call(colValue) === + '[object Object]' || + Object.prototype.toString.call(colValue) === '[object Array]' + ) { + colValue = JSON.stringify(colValue); + } + if (typeof colValue === 'string') { + if (colValue.includes('"')) { + // Has quote in data, escape and quote + // If the value contains both a quote and delimiter, adding quotes and escaping will take care of both scenarios + colValue = colValue.split('"').join('""'); + return `"${colValue}"`; + } else if (colValue.includes(',')) { + // Has delimiter in data, surround with quote (which the value doesn't already contain) + return `"${colValue}"`; + } else { + // No quote or delimiter, just include plainly + return `${colValue}`; + } + } else if (colValue === undefined) { + // Export as empty CSV field + return ''; + } else { + return `${colValue}`; + } } - } else if (colValue === undefined) { - // Export as empty CSV field - return ''; - } else { - return `${colValue}`; + }) + .join(','); + csvString += row + '\n'; + } + + // Deliver to browser to download file + const element = document.createElement('a'); + const file = new Blob([csvString], { type: 'text/csv' }); + element.href = URL.createObjectURL(file); + element.download = `${className}.csv`; + document.body.appendChild(element); // Required for this to work in FireFox + element.click(); + document.body.removeChild(element); + }; + + if (!rows['*']) { + const objects = await query.find({ useMasterKey: true }); + processObjects(objects); + this.setState({ exporting: false, exportingCount: objects.length }); + } else { + let batch = []; + query.eachBatch( + (obj) => { + batch.push(...obj); + if (batch.length % 10 === 0) { + this.setState({ exportingCount: batch.length }); } - } - }).join(','); - csvString += row + '\n'; + const one_gigabyte = Math.pow(2, 30); + const size = + new TextEncoder().encode(JSON.stringify(batch)).length / + one_gigabyte; + if (size.length > 1) { + processObjects(batch); + batch = []; + } + if (obj.length !== 100) { + processObjects(batch); + batch = []; + this.setState({ exporting: false, exportingCount: 0 }); + } + }, + { useMasterKey: true } + ); } - - // Deliver to browser to download file - const element = document.createElement('a'); - const file = new Blob([csvString], { type: 'text/csv' }); - element.href = URL.createObjectURL(file); - element.download = `${className}.csv`; - document.body.appendChild(element); // Required for this to work in FireFox - element.click(); - document.body.removeChild(element); } getClassRelationColumns(className) { @@ -1545,6 +1646,7 @@ class Browser extends DashboardView { onEditSelectedRow={this.showEditRowDialog} onEditPermissions={this.onDialogToggle} onExportSelectedRows={this.showExportSelectedRowsDialog} + onExportSchema={this.showExportSchemaDialog} onSaveNewRow={this.saveNewRow} onShowPointerKey={this.showPointerKeyDialog} @@ -1659,6 +1761,14 @@ class Browser extends DashboardView { onCancel={() => this.setState({ showExportDialog: false })} onConfirm={() => this.exportClass(className)} /> ); + } else if (this.state.showExportSchemaDialog) { + extras = ( + this.setState({ showExportSchemaDialog: false })} + onConfirm={(...args) => this.exportSchema(...args)} /> + ); } else if (this.state.showAttachRowsDialog) { extras = ( this.confirmExportSelectedRows(this.state.rowsToExport)} + onConfirm={(type, indentation) => this.confirmExportSelectedRows(this.state.rowsToExport, type, indentation)} /> ); } @@ -1772,6 +1884,11 @@ class Browser extends DashboardView { ); } + else if (this.state.exporting) { + notification = ( + + ); + } return (
diff --git a/src/dashboard/Data/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index 8079c48a41..5076c9a141 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -40,6 +40,7 @@ let BrowserToolbar = ({ onAttachSelectedRows, onCloneSelectedRows, onExportSelectedRows, + onExportSchema, onExport, onRemoveColumn, onDeleteRows, @@ -257,6 +258,10 @@ let BrowserToolbar = ({ text={'Export all rows'} onClick={() => onExportSelectedRows({ '*': true })} /> + onExportSchema()} + /> )} {onAddRow &&
} diff --git a/src/dashboard/Data/Browser/ExportSchemaDialog.react.js b/src/dashboard/Data/Browser/ExportSchemaDialog.react.js new file mode 100644 index 0000000000..0f2d7d84c2 --- /dev/null +++ b/src/dashboard/Data/Browser/ExportSchemaDialog.react.js @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import Modal from 'components/Modal/Modal.react'; +import React from 'react'; +import Dropdown from 'components/Dropdown/Dropdown.react'; +import Field from 'components/Field/Field.react'; +import Label from 'components/Label/Label.react'; +import Option from 'components/Dropdown/Option.react'; +import Toggle from 'components/Toggle/Toggle.react'; + +export default class ExportSchemaDialog extends React.Component { + constructor(props) { + super(); + const classes = Object.keys(props.schema.toObject()).sort(); + classes.sort((a, b) => { + if (a[0] === '_' && b[0] !== '_') { + return -1; + } + if (b[0] === '_' && a[0] !== '_') { + return 1; + } + return a.toUpperCase() < b.toUpperCase() ? -1 : 1; + }); + this.state = { + all: false, + className: props.className, + classes + }; + } + + + render() { + return ( + this.props.onConfirm(this.state.className, this.state.all)}> + {!this.state.all && + } + input={ + this.setState({ className })}> + {this.state.classes.map(schema => )} + + } /> + } + } + input={ {this.setState({all})}} />} /> + + ); + } +} diff --git a/src/dashboard/Data/Browser/ExportSelectedRowsDialog.react.js b/src/dashboard/Data/Browser/ExportSelectedRowsDialog.react.js index 92f2ce50bf..0478c56d71 100644 --- a/src/dashboard/Data/Browser/ExportSelectedRowsDialog.react.js +++ b/src/dashboard/Data/Browser/ExportSelectedRowsDialog.react.js @@ -5,36 +5,94 @@ * This source code is licensed under the license found in the LICENSE file in * the root directory of this source tree. */ -import Modal from 'components/Modal/Modal.react'; -import React from 'react'; +import Modal from 'components/Modal/Modal.react'; +import React from 'react'; +import Dropdown from 'components/Dropdown/Dropdown.react'; +import Field from 'components/Field/Field.react'; +import Label from 'components/Label/Label.react'; +import Option from 'components/Dropdown/Option.react'; +import Toggle from 'components/Toggle/Toggle.react'; +import TextInput from 'components/TextInput/TextInput.react'; +import styles from 'dashboard/Data/Browser/ExportSelectedRowsDialog.scss'; export default class ExportSelectedRowsDialog extends React.Component { constructor() { super(); this.state = { - confirmation: '' + confirmation: '', + exportType: '.csv', + indentation: true, }; } valid() { + if (!this.props.selection['*']) { + return true; + } + if (this.state.confirmation !== 'export all') { + return false; + } return true; } + formatBytes(bytes) { + if (!+bytes) return '0 Bytes' + + const k = 1024 + const decimals = 2 + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}` +} + + render() { let selectionLength = Object.keys(this.props.selection).length; + const fileSize = new TextEncoder().encode(JSON.stringify(this.props.data, null, this.state.exportType === '.json' && this.state.indentation ? 2 : null)).length / this.props.data.length return ( - {} + onConfirm={() => this.props.onConfirm(this.state.exportType, this.state.indentation)}> + {this.props.selection['*'] &&
+
+ } + } + input={ + this.setState({ exportType })}> + + + + } /> + {this.state.exportType === '.json' && } + input={ {this.setState({indentation})}} />} /> + } + {this.props.selection['*'] && + } + input={ + this.setState({ confirmation })} /> + } /> + }
); } diff --git a/src/dashboard/Data/Browser/ExportSelectedRowsDialog.scss b/src/dashboard/Data/Browser/ExportSelectedRowsDialog.scss new file mode 100644 index 0000000000..2ced8f2386 --- /dev/null +++ b/src/dashboard/Data/Browser/ExportSelectedRowsDialog.scss @@ -0,0 +1,9 @@ +.row { + display: block; + position: relative; + height: 100px; + border-bottom: 1px solid #e0e0e1; +} +.label { + line-height: 16px; +} diff --git a/src/login/Login.js b/src/login/Login.js index 79f4de1b23..967da8b57e 100644 --- a/src/login/Login.js +++ b/src/login/Login.js @@ -28,10 +28,13 @@ export default class Login extends React.Component { } } + const url = new URL(window.location); + const redirect = url.searchParams.get('redirect'); this.state = { forgot: false, username: sessionStorage.getItem('username') || '', - password: sessionStorage.getItem('password') || '' + password: sessionStorage.getItem('password') || '', + redirect }; sessionStorage.clear(); setBasePath(props.path); @@ -106,6 +109,11 @@ export default class Login extends React.Component { ref={this.inputRefPass} /> } /> + {this.state.redirect && } { this.errors && this.errors.includes('one-time') ?