Added server conf endpoint.

New localDbSvc.getWorkspaceItems method used to export workspaces.
Added offline availability in the workspace management modal.
New accordion in the badge management modal.
Add badge creation checks in unit tests.
This commit is contained in:
Benoit Schweblin 2019-06-29 17:33:21 +01:00
parent 3f0597601e
commit 07d824faca
44 changed files with 497 additions and 252 deletions

View File

@ -16,9 +16,7 @@ module.exports = {
], ],
globals: { globals: {
"NODE_ENV": false, "NODE_ENV": false,
"VERSION": false, "VERSION": false
"GOOGLE_CLIENT_ID": false,
"GITHUB_CLIENT_ID": false
}, },
// check if imports actually resolve // check if imports actually resolve
'settings': { 'settings': {

View File

@ -19,9 +19,7 @@ module.exports = merge(baseWebpackConfig, {
devtool: 'source-map', devtool: 'source-map',
plugins: [ plugins: [
new webpack.DefinePlugin({ new webpack.DefinePlugin({
NODE_ENV: config.dev.env.NODE_ENV, NODE_ENV: config.dev.env.NODE_ENV
GOOGLE_CLIENT_ID: config.dev.env.GOOGLE_CLIENT_ID,
GITHUB_CLIENT_ID: config.dev.env.GITHUB_CLIENT_ID
}), }),
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage // https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.HotModuleReplacementPlugin(), new webpack.HotModuleReplacementPlugin(),

View File

@ -2,7 +2,5 @@ var merge = require('webpack-merge')
var prodEnv = require('./prod.env') var prodEnv = require('./prod.env')
module.exports = merge(prodEnv, { module.exports = merge(prodEnv, {
NODE_ENV: '"development"', NODE_ENV: '"development"'
GOOGLE_CLIENT_ID: '"241271498917-c3loeet001r90q6u79q484bsh5clg4fr.apps.googleusercontent.com"',
GITHUB_CLIENT_ID: '"cbf0cf25cfd026be23e1"'
}) })

View File

@ -24,7 +24,7 @@ module.exports = {
dev: { dev: {
env: require('./dev.env'), env: require('./dev.env'),
port: 8080, port: 8080,
autoOpenBrowser: true, autoOpenBrowser: false,
assetsSubDirectory: 'static', assetsSubDirectory: 'static',
assetsPublicPath: '/', assetsPublicPath: '/',
proxyTable: {}, proxyTable: {},

View File

@ -1,5 +1,3 @@
module.exports = { module.exports = {
NODE_ENV: '"production"', NODE_ENV: '"production"'
GOOGLE_CLIENT_ID: '"241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com"',
GITHUB_CLIENT_ID: '"30c1491057c9ad4dbd56"'
} }

37
server/conf.js Normal file
View File

@ -0,0 +1,37 @@
const pandocPath = process.env.PANDOC_PATH || 'pandoc';
const wkhtmltopdfPath = process.env.WKHTMLTOPDF_PATH || 'wkhtmltopdf';
const userBucketName = process.env.USER_BUCKET_NAME || 'stackedit-users';
const paypalUri = process.env.PAYPAL_URI || 'https://www.paypal.com/cgi-bin/webscr';
const paypalReceiverEmail = process.env.PAYPAL_RECEIVER_EMAIL;
const dropboxAppKey = process.env.DROPBOX_APP_KEY;
const dropboxAppKeyFull = process.env.DROPBOX_APP_KEY_FULL;
const githubClientId = process.env.GITHUB_CLIENT_ID;
const githubClientSecret = process.env.GITHUB_CLIENT_SECRET;
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleApiKey = process.env.GOOGLE_API_KEY;
const wordpressClientId = process.env.WORDPRESS_CLIENT_ID;
exports.values = {
pandocPath,
wkhtmltopdfPath,
userBucketName,
paypalUri,
paypalReceiverEmail,
dropboxAppKey,
dropboxAppKeyFull,
githubClientId,
githubClientSecret,
googleClientId,
googleApiKey,
wordpressClientId,
};
exports.publicValues = {
dropboxAppKey,
dropboxAppKeyFull,
githubClientId,
googleClientId,
googleApiKey,
wordpressClientId,
};

View File

@ -1,5 +1,6 @@
const qs = require('qs'); // eslint-disable-line import/no-extraneous-dependencies const qs = require('qs'); // eslint-disable-line import/no-extraneous-dependencies
const request = require('request'); const request = require('request');
const conf = require('./conf');
function githubToken(clientId, code) { function githubToken(clientId, code) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -8,7 +9,7 @@ function githubToken(clientId, code) {
url: 'https://github.com/login/oauth/access_token', url: 'https://github.com/login/oauth/access_token',
qs: { qs: {
client_id: clientId, client_id: clientId,
client_secret: process.env.GITHUB_SECRET, client_secret: conf.values.githubClientSecret,
code, code,
}, },
}, (err, res, body) => { }, (err, res, body) => {

View File

@ -6,6 +6,7 @@ const user = require('./user');
const github = require('./github'); const github = require('./github');
const pdf = require('./pdf'); const pdf = require('./pdf');
const pandoc = require('./pandoc'); const pandoc = require('./pandoc');
const conf = require('./conf');
const resolvePath = pathToResolve => path.join(__dirname, '..', pathToResolve); const resolvePath = pathToResolve => path.join(__dirname, '..', pathToResolve);
@ -24,6 +25,7 @@ module.exports = (app, serveV4) => {
} }
app.get('/oauth2/githubToken', github.githubToken); app.get('/oauth2/githubToken', github.githubToken);
app.get('/conf', (req, res) => res.send(conf.publicValues));
app.get('/userInfo', user.userInfo); app.get('/userInfo', user.userInfo);
app.post('/pdfExport', pdf.generate); app.post('/pdfExport', pdf.generate);
app.post('/pandocExport', pandoc.generate); app.post('/pandocExport', pandoc.generate);

View File

@ -3,6 +3,7 @@ const { spawn } = require('child_process');
const fs = require('fs'); const fs = require('fs');
const tmp = require('tmp'); const tmp = require('tmp');
const user = require('./user'); const user = require('./user');
const conf = require('./conf');
const outputFormats = { const outputFormats = {
asciidoc: 'text/plain', asciidoc: 'text/plain',
@ -90,10 +91,9 @@ exports.generate = (req, res) => {
reject(error); reject(error);
} }
const binPath = process.env.PANDOC_PATH || 'pandoc';
const format = outputFormat === 'pdf' ? 'latex' : outputFormat; const format = outputFormat === 'pdf' ? 'latex' : outputFormat;
params.push('-f', 'json', '-t', format, '-o', filePath); params.push('-f', 'json', '-t', format, '-o', filePath);
const pandoc = spawn(binPath, params, { const pandoc = spawn(conf.values.pandocPath, params, {
stdio: [ stdio: [
'pipe', 'pipe',
'ignore', 'ignore',

View File

@ -3,6 +3,7 @@ const { spawn } = require('child_process');
const fs = require('fs'); const fs = require('fs');
const tmp = require('tmp'); const tmp = require('tmp');
const user = require('./user'); const user = require('./user');
const conf = require('./conf');
/* eslint-disable no-var, prefer-arrow-callback, func-names */ /* eslint-disable no-var, prefer-arrow-callback, func-names */
function waitForJavaScript() { function waitForJavaScript() {
@ -127,10 +128,9 @@ exports.generate = (req, res) => {
params.push('--page-size', !authorizedPageSizes.includes(options.pageSize) ? 'A4' : options.pageSize); params.push('--page-size', !authorizedPageSizes.includes(options.pageSize) ? 'A4' : options.pageSize);
// Use a temp file as wkhtmltopdf can't access /dev/stdout on Amazon EC2 for some reason // Use a temp file as wkhtmltopdf can't access /dev/stdout on Amazon EC2 for some reason
const binPath = process.env.WKHTMLTOPDF_PATH || 'wkhtmltopdf';
params.push('--run-script', `${waitForJavaScript.toString()}waitForJavaScript()`); params.push('--run-script', `${waitForJavaScript.toString()}waitForJavaScript()`);
params.push('--window-status', 'done'); params.push('--window-status', 'done');
const wkhtmltopdf = spawn(binPath, params.concat('-', filePath), { const wkhtmltopdf = spawn(conf.values.wkhtmltopdfPath, params.concat('-', filePath), {
stdio: [ stdio: [
'pipe', 'pipe',
'ignore', 'ignore',

View File

@ -1,13 +1,8 @@
const request = require('request'); const request = require('request');
const AWS = require('aws-sdk'); const AWS = require('aws-sdk');
const verifier = require('google-id-token-verifier'); const verifier = require('google-id-token-verifier');
const conf = require('./conf');
const {
USER_BUCKET_NAME = 'stackedit-users',
PAYPAL_URI = 'https://www.paypal.com/cgi-bin/webscr',
PAYPAL_RECEIVER_EMAIL = 'stackedit.project@gmail.com',
GOOGLE_CLIENT_ID,
} = process.env;
const s3Client = new AWS.S3(); const s3Client = new AWS.S3();
const cb = (resolve, reject) => (err, res) => { const cb = (resolve, reject) => (err, res) => {
@ -20,7 +15,7 @@ const cb = (resolve, reject) => (err, res) => {
exports.getUser = id => new Promise((resolve, reject) => { exports.getUser = id => new Promise((resolve, reject) => {
s3Client.getObject({ s3Client.getObject({
Bucket: USER_BUCKET_NAME, Bucket: conf.values.userBucketName,
Key: id, Key: id,
}, cb(resolve, reject)); }, cb(resolve, reject));
}) })
@ -35,7 +30,7 @@ exports.getUser = id => new Promise((resolve, reject) => {
exports.putUser = (id, user) => new Promise((resolve, reject) => { exports.putUser = (id, user) => new Promise((resolve, reject) => {
s3Client.putObject({ s3Client.putObject({
Bucket: USER_BUCKET_NAME, Bucket: conf.values.userBucketName,
Key: id, Key: id,
Body: JSON.stringify(user), Body: JSON.stringify(user),
}, cb(resolve, reject)); }, cb(resolve, reject));
@ -43,13 +38,13 @@ exports.putUser = (id, user) => new Promise((resolve, reject) => {
exports.removeUser = id => new Promise((resolve, reject) => { exports.removeUser = id => new Promise((resolve, reject) => {
s3Client.deleteObject({ s3Client.deleteObject({
Bucket: USER_BUCKET_NAME, Bucket: conf.values.userBucketName,
Key: id, Key: id,
}, cb(resolve, reject)); }, cb(resolve, reject));
}); });
exports.getUserFromToken = idToken => new Promise((resolve, reject) => verifier exports.getUserFromToken = idToken => new Promise((resolve, reject) => verifier
.verify(idToken, GOOGLE_CLIENT_ID, cb(resolve, reject))) .verify(idToken, conf.values.googleClientId, cb(resolve, reject)))
.then(tokenInfo => exports.getUser(tokenInfo.sub)); .then(tokenInfo => exports.getUser(tokenInfo.sub));
exports.userInfo = (req, res) => exports.getUserFromToken(req.query.idToken) exports.userInfo = (req, res) => exports.getUserFromToken(req.query.idToken)
@ -78,7 +73,7 @@ exports.paypalIpn = (req, res, next) => Promise.resolve()
sponsorUntil = Date.now() + (5 * 366 * 24 * 60 * 60 * 1000); // 5 years sponsorUntil = Date.now() + (5 * 366 * 24 * 60 * 60 * 1000); // 5 years
} }
if ( if (
req.body.receiver_email !== PAYPAL_RECEIVER_EMAIL || req.body.receiver_email !== conf.values.paypalReceiverEmail ||
req.body.payment_status !== 'Completed' || req.body.payment_status !== 'Completed' ||
req.body.mc_currency !== 'USD' || req.body.mc_currency !== 'USD' ||
(req.body.txn_type !== 'web_accept' && req.body.txn_type !== 'subscr_payment') || (req.body.txn_type !== 'web_accept' && req.body.txn_type !== 'subscr_payment') ||
@ -90,7 +85,7 @@ exports.paypalIpn = (req, res, next) => Promise.resolve()
// Processing PayPal IPN // Processing PayPal IPN
req.body.cmd = '_notify-validate'; req.body.cmd = '_notify-validate';
return new Promise((resolve, reject) => request.post({ return new Promise((resolve, reject) => request.post({
uri: PAYPAL_URI, uri: conf.values.paypalUri,
form: req.body, form: req.body,
}, (err, response, body) => { }, (err, response, body) => {
if (err) { if (err) {

View File

@ -156,7 +156,7 @@ export default {
...sourceNode.item, ...sourceNode.item,
parentId: targetNode.item.id, parentId: targetNode.item.id,
}); });
badgeSvc.addBadge('moveFiles'); badgeSvc.addBadge(sourceNode.isFolder ? 'moveFolder' : 'moveFile');
} }
}, },
async onContextMenu(evt) { async onContextMenu(evt) {

View File

@ -3,7 +3,7 @@
<div class="notification__item flex flex--row flex--align-center" v-for="(item, idx) in items" :key="idx"> <div class="notification__item flex flex--row flex--align-center" v-for="(item, idx) in items" :key="idx">
<div class="notification__icon flex flex--column flex--center"> <div class="notification__icon flex flex--column flex--center">
<icon-alert v-if="item.type === 'error'"></icon-alert> <icon-alert v-if="item.type === 'error'"></icon-alert>
<icon-check-circle v-if="item.type === 'badge'"></icon-check-circle> <icon-check-circle v-else-if="item.type === 'badge'"></icon-check-circle>
<icon-information v-else></icon-information> <icon-information v-else></icon-information>
</div> </div>
<div class="notification__content"> <div class="notification__content">

View File

@ -175,8 +175,9 @@ export default {
p { p {
margin: 10px 15px; margin: 10px 15px;
line-height: 1.5; font-size: 0.9rem;
font-style: italic; opacity: 0.67;
line-height: 1.3;
} }
} }
</style> </style>

View File

@ -84,25 +84,25 @@
Print Print
</menu-entry> </menu-entry>
<hr> <hr>
<menu-entry @click.native="settings"> <menu-entry @click.native="badges">
<icon-settings slot="icon"></icon-settings> <icon-seal slot="icon"></icon-seal>
<div>Settings</div> <div><div class="menu-entry__label menu-entry__label--count">{{badgeCount}}/{{featureCount}}</div> Badges</div>
<span>Tweak application and keyboard shortcuts.</span> <span>List application features and earned badges.</span>
</menu-entry>
<menu-entry @click.native="accounts">
<icon-key slot="icon"></icon-key>
<div><div class="menu-entry__label menu-entry__label--count">{{accountCount}}</div> Accounts</div>
<span>Manage access to your external accounts.</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="templates"> <menu-entry @click.native="templates">
<icon-code-braces slot="icon"></icon-code-braces> <icon-code-braces slot="icon"></icon-code-braces>
<div><div class="menu-entry__label menu-entry__label--count">{{templateCount}}</div> Templates</div> <div><div class="menu-entry__label menu-entry__label--count">{{templateCount}}</div> Templates</div>
<span>Configure Handlebars templates for your exports.</span> <span>Configure Handlebars templates for your exports.</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="accounts"> <menu-entry @click.native="settings">
<icon-key slot="icon"></icon-key> <icon-settings slot="icon"></icon-settings>
<div><div class="menu-entry__label menu-entry__label--count">{{accountCount}}</div> User accounts</div> <div>Settings</div>
<span>Manage access to your external accounts.</span> <span>Tweak application and keyboard shortcuts.</span>
</menu-entry>
<menu-entry @click.native="badges">
<icon-seal slot="icon"></icon-seal>
<div><div class="menu-entry__label menu-entry__label--count">{{badgeCount}}/{{featureCount}}</div> Badges</div>
<span>List application features and earned badges.</span>
</menu-entry> </menu-entry>
<hr> <hr>
<menu-entry @click.native="setPanel('workspaceBackups')"> <menu-entry @click.native="setPanel('workspaceBackups')">
@ -111,10 +111,8 @@
</menu-entry> </menu-entry>
<menu-entry @click.native="reset"> <menu-entry @click.native="reset">
<icon-logout slot="icon"></icon-logout> <icon-logout slot="icon"></icon-logout>
<div>Reset application</div> Reset application
<span>Sign out and clean all workspace data.</span>
</menu-entry> </menu-entry>
<hr>
<menu-entry @click.native="about"> <menu-entry @click.native="about">
<icon-help-circle slot="icon"></icon-help-circle> <icon-help-circle slot="icon"></icon-help-circle>
About StackEdit About StackEdit
@ -218,7 +216,7 @@ export default {
async reset() { async reset() {
try { try {
await store.dispatch('modal/open', 'reset'); await store.dispatch('modal/open', 'reset');
window.location.href = '#reset=true'; localStorage.setItem('resetStackEdit', '1');
window.location.reload(); window.location.reload();
} catch (e) { /* Cancel */ } } catch (e) { /* Cancel */ }
}, },

View File

@ -4,9 +4,6 @@
<p>{{currentFileName}} can't be published as it's a temporary file.</p> <p>{{currentFileName}} can't be published as it's a temporary file.</p>
</div> </div>
<div v-else> <div v-else>
<div class="side-bar__info" v-if="noToken">
<p>You have to <b>link an account</b> to start publishing files.</p>
</div>
<div class="side-bar__info" v-if="publishLocations.length"> <div class="side-bar__info" v-if="publishLocations.length">
<p>{{currentFileName}} is already published.</p> <p>{{currentFileName}} is already published.</p>
<menu-entry @click.native="requestPublish"> <menu-entry @click.native="requestPublish">
@ -20,6 +17,9 @@
<span>Manage publication locations for {{currentFileName}}.</span> <span>Manage publication locations for {{currentFileName}}.</span>
</menu-entry> </menu-entry>
</div> </div>
<div class="side-bar__info" v-else-if="noToken">
<p>You have to link an account to start publishing files.</p>
</div>
<hr> <hr>
<div v-for="token in bloggerTokens" :key="'blogger-' + token.sub"> <div v-for="token in bloggerTokens" :key="'blogger-' + token.sub">
<menu-entry @click.native="publishBlogger(token)"> <menu-entry @click.native="publishBlogger(token)">
@ -181,8 +181,13 @@ export default {
return tokensToArray(store.getters['data/zendeskTokensBySub']); return tokensToArray(store.getters['data/zendeskTokensBySub']);
}, },
noToken() { noToken() {
return Object.values(store.getters['data/tokensByType']) return !this.bloggerTokens.length
.every(tokens => !Object.keys(tokens).length); && !this.dropboxTokens.length
&& !this.githubTokens.length
&& !this.gitlabTokens.length
&& !this.googleDriveTokens.length
&& !this.wordpressTokens.length
&& !this.zendeskTokens.length;
}, },
}, },
methods: { methods: {

View File

@ -4,9 +4,6 @@
<p>{{currentFileName}} can't be synced as it's a temporary file.</p> <p>{{currentFileName}} can't be synced as it's a temporary file.</p>
</div> </div>
<div v-else> <div v-else>
<div class="side-bar__info" v-if="noToken">
<p>You have to <b>link an account</b> to start syncing files.</p>
</div>
<div class="side-bar__info" v-if="syncLocations.length"> <div class="side-bar__info" v-if="syncLocations.length">
<p>{{currentFileName}} is already synchronized.</p> <p>{{currentFileName}} is already synchronized.</p>
<menu-entry @click.native="requestSync"> <menu-entry @click.native="requestSync">
@ -20,6 +17,9 @@
<span>Manage synchronized locations for {{currentFileName}}.</span> <span>Manage synchronized locations for {{currentFileName}}.</span>
</menu-entry> </menu-entry>
</div> </div>
<div class="side-bar__info" v-else-if="noToken">
<p>You have to link an account to start syncing files.</p>
</div>
<hr> <hr>
<div v-for="token in dropboxTokens" :key="token.sub"> <div v-for="token in dropboxTokens" :key="token.sub">
<menu-entry @click.native="openDropbox(token)"> <menu-entry @click.native="openDropbox(token)">

View File

@ -17,15 +17,19 @@
</template> </template>
<script> <script>
import FileSaver from 'file-saver';
import MenuEntry from './common/MenuEntry'; import MenuEntry from './common/MenuEntry';
import utils from '../../services/utils';
import store from '../../store'; import store from '../../store';
import backupSvc from '../../services/backupSvc'; import backupSvc from '../../services/backupSvc';
import localDbSvc from '../../services/localDbSvc';
export default { export default {
components: { components: {
MenuEntry, MenuEntry,
}, },
computed: {
workspaceId: () => store.getters['workspace/currentWorkspace'].id,
},
methods: { methods: {
onImportBackup(evt) { onImportBackup(evt) {
const file = evt.target.files[0]; const file = evt.target.files[0];
@ -44,11 +48,16 @@ export default {
} }
}, },
exportWorkspace() { exportWorkspace() {
window.location.href = utils.addQueryParams('app', { const allItemsById = {};
...utils.queryParams, localDbSvc.getWorkspaceItems(this.workspaceId, (item) => {
exportWorkspace: true, allItemsById[item.id] = item;
}, true); }, () => {
window.location.reload(); const backup = JSON.stringify(allItemsById);
const blob = new Blob([backup], {
type: 'text/plain;charset=utf-8',
});
FileSaver.saveAs(blob, 'StackEdit workspace.json');
});
}, },
}, },
}; };

View File

@ -1,5 +1,11 @@
<template> <template>
<div class="side-bar__panel side-bar__panel--menu"> <div class="side-bar__panel side-bar__panel--menu">
<menu-entry @click.native="manageWorkspaces">
<icon-database slot="icon"></icon-database>
<div><div class="menu-entry__label menu-entry__label--count">{{workspaceCount}}</div> Manage workspaces</div>
<span>List, rename, remove workspaces</span>
</menu-entry>
<hr>
<div class="workspace" v-for="(workspace, id) in workspacesById" :key="id"> <div class="workspace" v-for="(workspace, id) in workspacesById" :key="id">
<menu-entry :href="workspace.url" target="_blank"> <menu-entry :href="workspace.url" target="_blank">
<icon-provider slot="icon" :provider-id="workspace.providerId"></icon-provider> <icon-provider slot="icon" :provider-id="workspace.providerId"></icon-provider>
@ -23,10 +29,6 @@
<icon-provider slot="icon" provider-id="googleDriveWorkspace"></icon-provider> <icon-provider slot="icon" provider-id="googleDriveWorkspace"></icon-provider>
<span>Add a <b>Google Drive</b> workspace</span> <span>Add a <b>Google Drive</b> workspace</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="manageWorkspaces">
<icon-database slot="icon"></icon-database>
<span><div class="menu-entry__label menu-entry__label--count">{{workspaceCount}}</div> Manage workspaces</span>
</menu-entry>
</div> </div>
</template> </template>

View File

@ -22,15 +22,15 @@
</div> </div>
</div> </div>
<div class="account-entry__row"> <div class="account-entry__row">
<span v-if="entry.userId"> <span class="account-entry__field" v-if="entry.userId">
<b>User ID:</b> <b>User ID:</b>
{{entry.userId}} {{entry.userId}}
</span> </span>
<span v-if="entry.url"> <span class="account-entry__field" v-if="entry.url">
<b>URL:</b> <b>URL:</b>
{{entry.url}} {{entry.url}}
</span> </span>
<span v-if="entry.scopes"> <span class="account-entry__field" v-if="entry.scopes">
<b>Scopes:</b> <b>Scopes:</b>
{{entry.scopes.join(', ')}} {{entry.scopes.join(', ')}}
</span> </span>
@ -230,8 +230,12 @@ $button-size: 30px;
.account-entry__row { .account-entry__row {
border-top: 1px solid $hr-color; border-top: 1px solid $hr-color;
opacity: 0.5;
font-size: 0.67em; font-size: 0.67em;
padding: 0.25em 0;
}
.account-entry__field {
opacity: 0.5;
} }
.account-entry__icon { .account-entry__icon {

View File

@ -6,20 +6,21 @@
</div> </div>
<p v-if="badgeCount > 1">{{badgeCount}} badges earned</p> <p v-if="badgeCount > 1">{{badgeCount}} badges earned</p>
<p v-else>{{badgeCount}} badge earned</p> <p v-else>{{badgeCount}} badge earned</p>
<div class="badge-entry" :class="{'badge-entry--earned': badge.isEarned}" v-for="badge in badgeTree" :key="badge.featureId"> <div class="badge-entry" v-for="badge in badgeTree" :key="badge.featureId">
<div class="flex flex--row"> <div class="flex flex--row">
<icon-seal></icon-seal> <icon-seal class="badge-entry__icon" :class="{'badge-entry__icon--earned': badge.isEarned, 'badge-entry__icon--some-earned': badge.hasSomeEarned}"></icon-seal>
<div> <div>
<span class="badge-entry__name">{{badge.name}}</span> <span class="badge-entry__name" :class="{'badge-entry__name--earned': badge.isEarned, 'badge-entry__name--some-earned': badge.hasSomeEarned}">{{badge.name}}</span>
<span class="badge-entry__description">&mdash; {{badge.description}}</span> <span class="badge-entry__description">&mdash; {{badge.description}}</span>
<div class="badge-entry" :class="{'badge-entry--earned': child.isEarned}" v-for="child in badge.children" :key="child.featureId"> <a href="javascript:void(0)" v-if="!shown[badge.featureId]" @click="show(badge.featureId)">Show</a>
<div class="badge-entry" v-else v-for="child in badge.children" :key="child.featureId">
<div class="flex flex--row"> <div class="flex flex--row">
<icon-seal></icon-seal> <icon-seal class="badge-entry__icon" :class="{'badge-entry__icon--earned': child.isEarned}"></icon-seal>
<div> <div>
<span class="badge-entry__name">{{child.name}}</span> <span class="badge-entry__name" :class="{'badge-entry__name--earned': child.isEarned}">{{child.name}}</span>
<span class="badge-entry__description">&mdash; {{child.description}}</span> <span class="badge-entry__description">&mdash; {{child.description}}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@ -32,6 +33,7 @@
</template> </template>
<script> <script>
import Vue from 'vue';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ModalInner from './common/ModalInner'; import ModalInner from './common/ModalInner';
import store from '../../store'; import store from '../../store';
@ -40,6 +42,9 @@ export default {
components: { components: {
ModalInner, ModalInner,
}, },
data: () => ({
shown: {},
}),
computed: { computed: {
...mapGetters('modal', [ ...mapGetters('modal', [
'config', 'config',
@ -54,6 +59,11 @@ export default {
return store.getters['data/allBadges'].length; return store.getters['data/allBadges'].length;
}, },
}, },
methods: {
show(featureId) {
Vue.set(this.shown, featureId, true);
},
},
}; };
</script> </script>
@ -76,19 +86,24 @@ export default {
.badge-entry { .badge-entry {
font-size: 0.8em; font-size: 0.8em;
margin: 0.75rem 0 0; margin: 0.75rem 0;
}
svg {
width: 1.67em;
height: 1.67em;
margin-right: 0.25em;
opacity: 0.33;
flex: none;
} }
} }
.badge-entry--earned svg { .badge-entry__icon {
width: 1.67em;
height: 1.67em;
margin-right: 0.25em;
opacity: 0.3;
flex: none;
}
.badge-entry__icon--some-earned {
opacity: 0.5;
color: goldenrod;
}
.badge-entry__icon--earned {
opacity: 1; opacity: 1;
color: goldenrod; color: goldenrod;
} }
@ -100,10 +115,10 @@ export default {
.badge-entry__name { .badge-entry__name {
font-size: 1.2em; font-size: 1.2em;
font-weight: bold; font-weight: bold;
opacity: 0.5; opacity: 0.4;
}
.badge-entry--earned & { .badge-entry__name--earned {
opacity: 1; opacity: 1;
}
} }
</style> </style>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-database></icon-database> <icon-database></icon-database>
</div> </div>
<p>The following workspaces are locally available:</p> <p>The following workspaces are accessible:</p>
<div class="workspace-entry flex flex--column" v-for="(workspace, id) in workspacesById" :key="id"> <div class="workspace-entry flex flex--column" v-for="(workspace, id) in workspacesById" :key="id">
<div class="flex flex--column"> <div class="flex flex--column">
<div class="workspace-entry__header flex flex--row flex--align-center"> <div class="workspace-entry__header flex flex--row flex--align-center">
@ -48,11 +48,13 @@
</a> </a>
</div> </div>
</div> </div>
<div>
<span class="workspace-entry__offline" v-if="availableOffline[id]">
available offline
</span>
</div>
</div> </div>
</div> </div>
<div class="modal__info">
<b>ProTip:</b> Workspaces are accessible offline, try it!
</div>
</div> </div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button button--resolve" @click="config.resolve()">Close</button> <button class="button button--resolve" @click="config.resolve()">Close</button>
@ -61,11 +63,13 @@
</template> </template>
<script> <script>
import Vue from 'vue';
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import ModalInner from './common/ModalInner'; import ModalInner from './common/ModalInner';
import workspaceSvc from '../../services/workspaceSvc'; import workspaceSvc from '../../services/workspaceSvc';
import store from '../../store'; import store from '../../store';
import badgeSvc from '../../services/badgeSvc'; import badgeSvc from '../../services/badgeSvc';
import localDbSvc from '../../services/localDbSvc';
export default { export default {
components: { components: {
@ -74,6 +78,7 @@ export default {
data: () => ({ data: () => ({
editedId: null, editedId: null,
editingName: '', editingName: '',
availableOffline: {},
}), }),
computed: { computed: {
...mapGetters('modal', [ ...mapGetters('modal', [
@ -124,6 +129,14 @@ export default {
} }
}, },
}, },
created() {
Object.keys(this.workspacesById).forEach(async (workspaceId) => {
const cancel = localDbSvc.getWorkspaceItems(workspaceId, () => {
Vue.set(this.availableOffline, workspaceId, true);
cancel();
});
});
},
}; };
</script> </script>
@ -208,4 +221,13 @@ $small-button-size: 22px;
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
} }
} }
.workspace-entry__offline {
font-size: 0.8rem;
line-height: 1;
padding: 0.15em 0.35em;
border-radius: 3px;
color: #fff;
background-color: darken($error-color, 10);
}
</style> </style>

View File

@ -19,7 +19,8 @@ export default {
'settings', 'settings',
'layoutSettings', 'layoutSettings',
'tokens', 'tokens',
'badges', 'badgeCreations',
'serverConf',
], ],
textMaxLength: 250000, textMaxLength: 250000,
defaultName: 'Untitled', defaultName: 'Untitled',

View File

@ -1,13 +1,3 @@
class Badge {
constructor(featureId, name, description, children, isEarned) {
this.featureId = featureId;
this.name = name;
this.description = description;
this.children = children;
this.isEarned = isEarned;
}
}
class Feature { class Feature {
constructor(id, badgeName, description, children = null) { constructor(id, badgeName, description, children = null) {
this.id = id; this.id = id;
@ -16,13 +6,20 @@ class Feature {
this.children = children; this.children = children;
} }
toBadge(earnings) { toBadge(badgeCreations) {
const children = this.children const children = this.children
? this.children.map(child => child.toBadge(earnings)) ? this.children.map(child => child.toBadge(badgeCreations))
: null; : null;
return new Badge(this.id, this.badgeName, this.description, children, children return {
? children.every(child => child.isEarned) featureId: this.id,
: !!earnings[this.id]); name: this.badgeName,
description: this.description,
children,
isEarned: children
? children.every(child => child.isEarned)
: !!badgeCreations[this.id],
hasSomeEarned: children && children.some(child => child.isEarned),
};
} }
} }
@ -42,6 +39,16 @@ export default [
'Renamer', 'Renamer',
'Use the name field in the navigation bar to rename the current file.', 'Use the name field in the navigation bar to rename the current file.',
), ),
new Feature(
'toggleExplorer',
'Explorer toggler',
'Use the navigation bar to toggle the explorer.',
),
new Feature(
'toggleSideBar',
'Side bar toggler',
'Use the navigation bar to toggle the side bar.',
),
], ],
), ),
new Feature( new Feature(
@ -65,9 +72,14 @@ export default [
'Use the file explorer to create a new folder in your workspace.', 'Use the file explorer to create a new folder in your workspace.',
), ),
new Feature( new Feature(
'moveFiles', 'moveFile',
'File mover', 'File mover',
'Drag files in the file explorer to move them around.', 'Drag a file in the file explorer to move it in another folder.',
),
new Feature(
'moveFolder',
'Folder mover',
'Drag a folder in the file explorer to move it in another folder.',
), ),
new Feature( new Feature(
'renameFile', 'renameFile',
@ -80,15 +92,57 @@ export default [
'Use the file explorer to rename a folder in your workspace.', 'Use the file explorer to rename a folder in your workspace.',
), ),
new Feature( new Feature(
'removeFiles', 'removeFile',
'File remover', 'File remover',
'Use the file explorer to remove files in your workspace.', 'Use the file explorer to remove a file in your workspace.',
),
new Feature(
'removeFolder',
'Folder remover',
'Use the file explorer to remove a folder in your workspace.',
),
],
),
new Feature(
'buttonBar',
'Button bar expert',
'Use the button bar to customize the editor layout and to toggle features.',
[
new Feature(
'toggleNavigationBar',
'Navigation bar toggler',
'Use the button bar to toggle the navigation bar.',
),
new Feature(
'toggleSidePreview',
'Side preview toggler',
'Use the button bar to toggle the side preview.',
),
new Feature(
'toggleEditor',
'Editor toggler',
'Use the button bar to toggle the editor.',
),
new Feature(
'toggleFocusMode',
'Focused',
'Use the button bar to toggle the focus mode. This mode keeps the caret vertically centered while typing.',
),
new Feature(
'toggleScrollSync',
'Scroll sync toggler',
'Use the button bar to toggle the scroll sync feature. This feature links the editor and the preview scrollbars.',
),
new Feature(
'toggleStatusBar',
'Status bar toggler',
'Use the button bar to toggle the status bar.',
), ),
], ],
), ),
new Feature( new Feature(
'signIn', 'signIn',
'Logged in', 'Signed in',
'Sign in with Google, sync your main workspace and unlock functionalities.', 'Sign in with Google, sync your main workspace and unlock functionalities.',
[ [
new Feature( new Feature(
@ -143,7 +197,7 @@ export default [
new Feature( new Feature(
'manageAccounts', 'manageAccounts',
'Account manager', 'Account manager',
'Link all kinds of external accounts and use the "User accounts" dialog to manage them.', 'Link all kinds of external accounts and use the "Accounts" dialog to manage them.',
[ [
new Feature( new Feature(
'addBloggerAccount', 'addBloggerAccount',
@ -188,7 +242,7 @@ export default [
new Feature( new Feature(
'removeAccount', 'removeAccount',
'Revoker', 'Revoker',
'Use the "User accounts" dialog to remove access to an external account.', 'Use the "Accounts" dialog to remove access to an external account.',
), ),
], ],
), ),

View File

@ -13,7 +13,7 @@ The file explorer is accessible using the button in left corner of the navigatio
## Switch to another file ## Switch to another file
All your files are listed in the file explorer. You can switch from one to another by clicking a file in the list. All your files and folders are presented as a tree in the file explorer. You can switch from one to another by clicking a file in the tree.
## Rename a file ## Rename a file

View File

@ -16,7 +16,7 @@ const showInfo = () => {
export default { export default {
addBadge(featureId) { addBadge(featureId) {
if (!store.getters['data/badges'][featureId]) { if (!store.getters['data/badgeCreations'][featureId]) {
if (!lastEarnedFeatureIds) { if (!lastEarnedFeatureIds) {
const earnedFeatureIds = store.getters['data/allBadges'] const earnedFeatureIds = store.getters['data/allBadges']
.filter(badge => badge.isEarned) .filter(badge => badge.isEarned)
@ -24,7 +24,7 @@ export default {
lastEarnedFeatureIds = new Set(earnedFeatureIds); lastEarnedFeatureIds = new Set(earnedFeatureIds);
} }
store.dispatch('data/patchBadges', { store.dispatch('data/patchBadgeCreations', {
[featureId]: { [featureId]: {
created: Date.now(), created: Date.now(),
}, },

View File

@ -62,7 +62,6 @@ export default {
} else { } else {
workspaceSvc.deleteFile(id); workspaceSvc.deleteFile(id);
} }
badgeSvc.addBadge('removeFiles');
}; };
if (selectedNode === store.getters['explorer/selectedNode']) { if (selectedNode === store.getters['explorer/selectedNode']) {
@ -78,8 +77,10 @@ export default {
store.commit('folder/deleteItem', folderNode.item.id); store.commit('folder/deleteItem', folderNode.item.id);
}; };
recursiveDelete(selectedNode); recursiveDelete(selectedNode);
badgeSvc.addBadge('removeFolder');
} else { } else {
deleteFile(selectedNode.item.id); deleteFile(selectedNode.item.id);
badgeSvc.addBadge('removeFile');
} }
if (doClose) { if (doClose) {
// Close the current file by opening the last opened, not deleted one // Close the current file by opening the last opened, not deleted one

View File

@ -1,23 +1,23 @@
import FileSaver from 'file-saver';
import utils from './utils'; import utils from './utils';
import store from '../store'; import store from '../store';
import welcomeFile from '../data/welcomeFile.md'; import welcomeFile from '../data/welcomeFile.md';
import workspaceSvc from './workspaceSvc'; import workspaceSvc from './workspaceSvc';
import constants from '../data/constants'; import constants from '../data/constants';
const deleteMarkerMaxAge = 1000;
const dbVersion = 1; const dbVersion = 1;
const dbStoreName = 'objects'; const dbStoreName = 'objects';
const { exportWorkspace } = utils.queryParams;
const { silent } = utils.queryParams; const { silent } = utils.queryParams;
const resetApp = utils.queryParams.reset; const resetApp = localStorage.getItem('resetStackEdit');
const deleteMarkerMaxAge = 1000; if (resetApp) {
localStorage.removeItem('resetStackEdit');
}
class Connection { class Connection {
constructor() { constructor(workspaceId = store.getters['workspace/currentWorkspace'].id) {
this.getTxCbs = []; this.getTxCbs = [];
// Make the DB name // Make the DB name
const workspaceId = store.getters['workspace/currentWorkspace'].id;
this.dbName = utils.getDbName(workspaceId); this.dbName = utils.getDbName(workspaceId);
// Init connection // Init connection
@ -264,7 +264,7 @@ const localDbSvc = {
// DB item is different from the corresponding store item // DB item is different from the corresponding store item
this.hashMap[dbItem.type][dbItem.id] = dbItem.hash; this.hashMap[dbItem.type][dbItem.id] = dbItem.hash;
// Update content only if it exists in the store // Update content only if it exists in the store
if (storeItem || !contentTypes[dbItem.type] || exportWorkspace) { if (storeItem || !contentTypes[dbItem.type]) {
// Put item in the store // Put item in the store
dbItem.tx = undefined; dbItem.tx = undefined;
store.commit(`${dbItem.type}/setItem`, dbItem); store.commit(`${dbItem.type}/setItem`, dbItem);
@ -327,7 +327,7 @@ const localDbSvc = {
* Create the connection and start syncing. * Create the connection and start syncing.
*/ */
async init() { async init() {
// Reset the app if reset flag was passed // Reset the app if the reset flag was passed
if (resetApp) { if (resetApp) {
await Promise.all(Object.keys(store.getters['workspace/workspacesById']) await Promise.all(Object.keys(store.getters['workspace/workspacesById'])
.map(workspaceId => workspaceSvc.removeWorkspace(workspaceId))); .map(workspaceId => workspaceSvc.removeWorkspace(workspaceId)));
@ -344,16 +344,6 @@ const localDbSvc = {
// Load the DB // Load the DB
await localDbSvc.sync(); await localDbSvc.sync();
// If exportWorkspace parameter was provided
if (exportWorkspace) {
const backup = JSON.stringify(store.getters.allItemsById);
const blob = new Blob([backup], {
type: 'text/plain;charset=utf-8',
});
FileSaver.saveAs(blob, 'StackEdit workspace.json');
throw new Error('RELOAD');
}
// Watch workspace deletions and persist them as soon as possible // Watch workspace deletions and persist them as soon as possible
// to make the changes available to reloading workspace tabs. // to make the changes available to reloading workspace tabs.
store.watch( store.watch(
@ -438,6 +428,27 @@ const localDbSvc = {
{ immediate: true }, { immediate: true },
); );
}, },
getWorkspaceItems(workspaceId, onItem, onFinish = () => {}) {
const connection = new Connection(workspaceId);
connection.createTx((tx) => {
const dbStore = tx.objectStore(dbStoreName);
const index = dbStore.index('tx');
index.openCursor().onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
onItem(cursor.value);
cursor.continue();
} else {
connection.db.close();
onFinish();
}
};
});
// Return a cancel function
return () => connection.db.close();
},
}; };
const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`) const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)

View File

@ -8,7 +8,10 @@ const silentAuthorizeTimeout = 15 * 1000; // 15 secondes (which will be reattemp
const networkTimeout = 30 * 1000; // 30 sec const networkTimeout = 30 * 1000; // 30 sec
let isConnectionDown = false; let isConnectionDown = false;
const userInactiveAfter = 3 * 60 * 1000; // 3 minutes (twice the default sync period) const userInactiveAfter = 3 * 60 * 1000; // 3 minutes (twice the default sync period)
let lastActivity = 0;
let lastFocus = 0;
let isConfLoading = false;
let isConfLoaded = false;
function parseHeaders(xhr) { function parseHeaders(xhr) {
const pairs = xhr.getAllResponseHeaders().trim().split('\n'); const pairs = xhr.getAllResponseHeaders().trim().split('\n');
@ -31,21 +34,20 @@ function isRetriable(err) {
} }
export default { export default {
init() { async init() {
// Keep track of the last user activity // Keep track of the last user activity
this.lastActivity = 0;
const setLastActivity = () => { const setLastActivity = () => {
this.lastActivity = Date.now(); lastActivity = Date.now();
}; };
window.document.addEventListener('mousedown', setLastActivity); window.document.addEventListener('mousedown', setLastActivity);
window.document.addEventListener('keydown', setLastActivity); window.document.addEventListener('keydown', setLastActivity);
window.document.addEventListener('touchstart', setLastActivity); window.document.addEventListener('touchstart', setLastActivity);
// Keep track of the last window focus // Keep track of the last window focus
this.lastFocus = 0; lastFocus = 0;
const setLastFocus = () => { const setLastFocus = () => {
this.lastFocus = Date.now(); lastFocus = Date.now();
localStorage.setItem(store.getters['workspace/lastFocusKey'], this.lastFocus); localStorage.setItem(store.getters['workspace/lastFocusKey'], lastFocus);
setLastActivity(); setLastActivity();
}; };
if (document.hasFocus()) { if (document.hasFocus()) {
@ -53,7 +55,7 @@ export default {
} }
window.addEventListener('focus', setLastFocus); window.addEventListener('focus', setLastFocus);
// Check browser is online periodically // Check that browser is online periodically
const checkOffline = async () => { const checkOffline = async () => {
const isBrowserOffline = window.navigator.onLine === false; const isBrowserOffline = window.navigator.onLine === false;
if (!isBrowserOffline if (!isBrowserOffline
@ -90,23 +92,42 @@ export default {
store.dispatch('notification/error', 'You are offline.'); store.dispatch('notification/error', 'You are offline.');
} else { } else {
store.dispatch('notification/info', 'You are back online!'); store.dispatch('notification/info', 'You are back online!');
this.getServerConf();
} }
} }
}; };
utils.setInterval(checkOffline, 1000); utils.setInterval(checkOffline, 1000);
window.addEventListener('online', () => { window.addEventListener('online', () => {
isConnectionDown = false; isConnectionDown = false;
checkOffline(); checkOffline();
}); });
window.addEventListener('offline', checkOffline); window.addEventListener('offline', checkOffline);
await checkOffline();
this.getServerConf();
},
async getServerConf() {
if (!store.state.offline && !isConfLoading && !isConfLoaded) {
try {
isConfLoading = true;
const res = await this.request({ url: 'conf' });
await store.dispatch('data/setServerConf', res.body);
isConfLoaded = true;
} finally {
isConfLoading = false;
}
}
}, },
isWindowFocused() { isWindowFocused() {
// We don't use state.workspace.lastFocus as it's not reactive // We don't use state.workspace.lastFocus as it's not reactive
const storedLastFocus = localStorage.getItem(store.getters['workspace/lastFocusKey']); const storedLastFocus = localStorage.getItem(store.getters['workspace/lastFocusKey']);
return parseInt(storedLastFocus, 10) === this.lastFocus; return parseInt(storedLastFocus, 10) === lastFocus;
}, },
isUserActive() { isUserActive() {
return this.lastActivity > Date.now() - userInactiveAfter && this.isWindowFocused(); return lastActivity > Date.now() - userInactiveAfter && this.isWindowFocused();
},
isConfLoaded() {
return !!Object.keys(store.getters['data/serverConf']).length;
}, },
async loadScript(url) { async loadScript(url) {
if (!scriptLoadingPromises[url]) { if (!scriptLoadingPromises[url]) {
@ -217,15 +238,15 @@ export default {
throw e; throw e;
} }
}, },
async request(configParam, offlineCheck = false) { async request(config, offlineCheck = false) {
let retryAfter = 500; // 500 ms let retryAfter = 500; // 500 ms
const maxRetryAfter = 10 * 1000; // 10 sec const maxRetryAfter = 10 * 1000; // 10 sec
const config = Object.assign({}, configParam); const sanitizedConfig = Object.assign({}, config);
config.timeout = config.timeout || networkTimeout; sanitizedConfig.timeout = sanitizedConfig.timeout || networkTimeout;
config.headers = Object.assign({}, config.headers); sanitizedConfig.headers = Object.assign({}, sanitizedConfig.headers);
if (config.body && typeof config.body === 'object') { if (sanitizedConfig.body && typeof sanitizedConfig.body === 'object') {
config.body = JSON.stringify(config.body); sanitizedConfig.body = JSON.stringify(sanitizedConfig.body);
config.headers['Content-Type'] = 'application/json'; sanitizedConfig.headers['Content-Type'] = 'application/json';
} }
const attempt = async () => { const attempt = async () => {
@ -236,7 +257,7 @@ export default {
} }
const xhr = new window.XMLHttpRequest(); const xhr = new window.XMLHttpRequest();
xhr.withCredentials = config.withCredentials || false; xhr.withCredentials = sanitizedConfig.withCredentials || false;
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
xhr.abort(); xhr.abort();
@ -247,7 +268,7 @@ export default {
} else { } else {
reject(new Error('Network request timeout.')); reject(new Error('Network request timeout.'));
} }
}, config.timeout); }, sanitizedConfig.timeout);
xhr.onload = () => { xhr.onload = () => {
if (offlineCheck) { if (offlineCheck) {
@ -257,9 +278,9 @@ export default {
const result = { const result = {
status: xhr.status, status: xhr.status,
headers: parseHeaders(xhr), headers: parseHeaders(xhr),
body: config.blob ? xhr.response : xhr.responseText, body: sanitizedConfig.blob ? xhr.response : xhr.responseText,
}; };
if (!config.raw && !config.blob) { if (!sanitizedConfig.raw && !sanitizedConfig.blob) {
try { try {
result.body = JSON.parse(result.body); result.body = JSON.parse(result.body);
} catch (e) { } catch (e) {
@ -284,17 +305,17 @@ export default {
} }
}; };
const url = utils.addQueryParams(config.url, config.params); const url = utils.addQueryParams(sanitizedConfig.url, sanitizedConfig.params);
xhr.open(config.method || 'GET', url); xhr.open(sanitizedConfig.method || 'GET', url);
Object.entries(config.headers).forEach(([key, value]) => { Object.entries(sanitizedConfig.headers).forEach(([key, value]) => {
if (value) { if (value) {
xhr.setRequestHeader(key, `${value}`); xhr.setRequestHeader(key, `${value}`);
} }
}); });
if (config.blob) { if (sanitizedConfig.blob) {
xhr.responseType = 'blob'; xhr.responseType = 'blob';
} }
xhr.send(config.body || null); xhr.send(sanitizedConfig.body || null);
}); });
} catch (err) { } catch (err) {
// Try again later in case of retriable error // Try again later in case of retriable error

View File

@ -124,7 +124,11 @@ export default new Provider({
}; };
}, },
async listFileRevisions({ token, syncLocation }) { async listFileRevisions({ token, syncLocation }) {
const entries = await dropboxHelper.listRevisions(token, syncLocation.dropboxFileId); const entries = await dropboxHelper.listRevisions({
token,
path: makePathRelative(token, syncLocation.path),
fileId: syncLocation.dropboxFileId,
});
return entries.map(entry => ({ return entries.map(entry => ({
id: entry.rev, id: entry.rev,
sub: `${dropboxHelper.subPrefix}:${(entry.sharing_info || {}).modified_by || token.sub}`, sub: `${dropboxHelper.subPrefix}:${(entry.sharing_info || {}).modified_by || token.sub}`,

View File

@ -5,9 +5,9 @@ import badgeSvc from '../../badgeSvc';
const getAppKey = (fullAccess) => { const getAppKey = (fullAccess) => {
if (fullAccess) { if (fullAccess) {
return 'lq6mwopab8wskas'; return store.getters['data/serverConf'].dropboxAppKeyFull;
} }
return 'sw0hlixhr8q1xk0'; return store.getters['data/serverConf'].dropboxAppKey;
}; };
const httpHeaderSafeJson = args => args && JSON.stringify(args) const httpHeaderSafeJson = args => args && JSON.stringify(args)
@ -60,6 +60,7 @@ export default {
* https://www.dropbox.com/developers/documentation/http/documentation#users-get_current_account * https://www.dropbox.com/developers/documentation/http/documentation#users-get_current_account
*/ */
async startOauth2(fullAccess, sub = null, silent = false) { async startOauth2(fullAccess, sub = null, silent = false) {
// Get an OAuth2 code
const { accessToken } = await networkSvc.startOauth2( const { accessToken } = await networkSvc.startOauth2(
'https://www.dropbox.com/oauth2/authorize', 'https://www.dropbox.com/oauth2/authorize',
{ {
@ -146,14 +147,21 @@ export default {
/** /**
* https://www.dropbox.com/developers/documentation/http/documentation#list-revisions * https://www.dropbox.com/developers/documentation/http/documentation#list-revisions
*/ */
async listRevisions(token, fileId) { async listRevisions({
token,
path,
fileId,
}) {
const res = await request(token, { const res = await request(token, {
method: 'POST', method: 'POST',
url: 'https://api.dropboxapi.com/2/files/list_revisions', url: 'https://api.dropboxapi.com/2/files/list_revisions',
body: { body: fileId ? {
path: fileId, path: fileId,
mode: 'id', mode: 'id',
limit: 100, limit: 100,
} : {
path,
limit: 100,
}, },
}); });
return res.body.entries; return res.body.entries;

View File

@ -4,7 +4,6 @@ import store from '../../../store';
import userSvc from '../../userSvc'; import userSvc from '../../userSvc';
import badgeSvc from '../../badgeSvc'; import badgeSvc from '../../badgeSvc';
const clientId = GITHUB_CLIENT_ID;
const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist']; const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist'];
const request = (token, options) => networkSvc.request({ const request = (token, options) => networkSvc.request({
@ -64,6 +63,9 @@ export default {
* https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/ * https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/
*/ */
async startOauth2(scopes, sub = null, silent = false) { async startOauth2(scopes, sub = null, silent = false) {
const clientId = store.getters['data/serverConf'].githubClientId;
// Get an OAuth2 code
const { code } = await networkSvc.startOauth2( const { code } = await networkSvc.startOauth2(
'https://github.com/login/oauth/authorize', 'https://github.com/login/oauth/authorize',
{ {

View File

@ -51,6 +51,7 @@ export default {
* https://docs.gitlab.com/ee/api/oauth2.html * https://docs.gitlab.com/ee/api/oauth2.html
*/ */
async startOauth2(serverUrl, applicationId, sub = null, silent = false) { async startOauth2(serverUrl, applicationId, sub = null, silent = false) {
// Get an OAuth2 code
const { accessToken } = await networkSvc.startOauth2( const { accessToken } = await networkSvc.startOauth2(
`${serverUrl}/oauth/authorize`, `${serverUrl}/oauth/authorize`,
{ {

View File

@ -4,8 +4,6 @@ import store from '../../../store';
import userSvc from '../../userSvc'; import userSvc from '../../userSvc';
import badgeSvc from '../../badgeSvc'; import badgeSvc from '../../badgeSvc';
const clientId = GOOGLE_CLIENT_ID;
const apiKey = 'AIzaSyC_M4RA9pY6XmM9pmFxlT59UPMO7aHr9kk';
const appsDomain = null; const appsDomain = null;
const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (tokens expire after 1h) const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (tokens expire after 1h)
@ -21,6 +19,7 @@ const checkIdToken = (idToken) => {
try { try {
const token = idToken.split('.'); const token = idToken.split('.');
const payload = JSON.parse(utils.decodeBase64(token[1])); const payload = JSON.parse(utils.decodeBase64(token[1]));
const clientId = store.getters['data/serverConf'].googleClientId;
return payload.aud === clientId && Date.now() + tokenExpirationMargin < payload.exp * 1000; return payload.aud === clientId && Date.now() + tokenExpirationMargin < payload.exp * 1000;
} catch (e) { } catch (e) {
return false; return false;
@ -40,6 +39,7 @@ if (utils.queryParams.providerId === 'googleDrive') {
* https://developers.google.com/people/api/rest/v1/people/get * https://developers.google.com/people/api/rest/v1/people/get
*/ */
const getUser = async (sub, token) => { const getUser = async (sub, token) => {
const apiKey = store.getters['data/serverConf'].googleApiKey;
const url = `https://people.googleapis.com/v1/people/${sub}?personFields=names,photos&key=${apiKey}`; const url = `https://people.googleapis.com/v1/people/${sub}?personFields=names,photos&key=${apiKey}`;
const { body } = await networkSvc.request(sub === 'me' && token const { body } = await networkSvc.request(sub === 'me' && token
? { ? {
@ -111,6 +111,9 @@ export default {
* https://developers.google.com/identity/protocols/OpenIDConnect * https://developers.google.com/identity/protocols/OpenIDConnect
*/ */
async startOauth2(scopes, sub = null, silent = false) { async startOauth2(scopes, sub = null, silent = false) {
const clientId = store.getters['data/serverConf'].googleClientId;
// Get an OAuth2 code
const { accessToken, expiresIn, idToken } = await networkSvc.startOauth2( const { accessToken, expiresIn, idToken } = await networkSvc.startOauth2(
'https://accounts.google.com/o/oauth2/v2/auth', 'https://accounts.google.com/o/oauth2/v2/auth',
{ {
@ -265,16 +268,6 @@ export default {
badgeSvc.addBadge('addGooglePhotosAccount'); badgeSvc.addBadge('addGooglePhotosAccount');
return token; return token;
}, },
async getSponsorship(token) {
const refreshedToken = await this.refreshToken(token);
return networkSvc.request({
method: 'GET',
url: 'userInfo',
params: {
idToken: refreshedToken.idToken,
},
}, true);
},
/** /**
* https://developers.google.com/drive/v3/reference/files/create * https://developers.google.com/drive/v3/reference/files/create

View File

@ -2,7 +2,6 @@ import networkSvc from '../../networkSvc';
import store from '../../../store'; import store from '../../../store';
import badgeSvc from '../../badgeSvc'; import badgeSvc from '../../badgeSvc';
const clientId = '23361';
const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (WordPress tokens expire after 2 weeks) const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (WordPress tokens expire after 2 weeks)
const request = (token, options) => networkSvc.request({ const request = (token, options) => networkSvc.request({
@ -19,6 +18,9 @@ export default {
* https://developer.wordpress.com/docs/oauth2/ * https://developer.wordpress.com/docs/oauth2/
*/ */
async startOauth2(sub = null, silent = false) { async startOauth2(sub = null, silent = false) {
const clientId = store.getters['data/serverConf'].wordpressClientId;
// Get an OAuth2 code
const { accessToken, expiresIn } = await networkSvc.startOauth2( const { accessToken, expiresIn } = await networkSvc.startOauth2(
'https://public-api.wordpress.com/oauth2/authorize', 'https://public-api.wordpress.com/oauth2/authorize',
{ {

View File

@ -17,6 +17,7 @@ export default {
* https://support.zendesk.com/hc/en-us/articles/203663836-Using-OAuth-authentication-with-your-application * https://support.zendesk.com/hc/en-us/articles/203663836-Using-OAuth-authentication-with-your-application
*/ */
async startOauth2(subdomain, clientId, sub = null, silent = false) { async startOauth2(subdomain, clientId, sub = null, silent = false) {
// Get an OAuth2 code
const { accessToken } = await networkSvc.startOauth2( const { accessToken } = await networkSvc.startOauth2(
`https://${subdomain}.zendesk.com/oauth/authorizations/new`, `https://${subdomain}.zendesk.com/oauth/authorizations/new`,
{ {

View File

@ -728,7 +728,7 @@ const syncWorkspace = async (skipContents = false) => {
if (workspace.id === 'main') { if (workspace.id === 'main') {
await syncDataItem('settings'); await syncDataItem('settings');
await syncDataItem('workspaces'); await syncDataItem('workspaces');
await syncDataItem('badges'); await syncDataItem('badgeCreations');
} }
await syncDataItem('templates'); await syncDataItem('templates');

View File

@ -11,6 +11,7 @@ import styledHtmlWithTocTemplate from '../data/templates/styledHtmlWithTocTempla
import jekyllSiteTemplate from '../data/templates/jekyllSiteTemplate.html'; import jekyllSiteTemplate from '../data/templates/jekyllSiteTemplate.html';
import constants from '../data/constants'; import constants from '../data/constants';
import features from '../data/features'; import features from '../data/features';
import badgeSvc from '../services/badgeSvc';
const itemTemplate = (id, data = {}) => ({ const itemTemplate = (id, data = {}) => ({
id, id,
@ -63,9 +64,12 @@ const patcher = id => ({ state, commit }, data) => {
}; };
// For layoutSettings // For layoutSettings
const layoutSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLayoutSettings', { const layoutSettingsToggler = (propertyName, featureId) => ({ getters, dispatch }, value) => {
[propertyName]: value === undefined ? !getters.layoutSettings[propertyName] : value, dispatch('patchLayoutSettings', {
}); [propertyName]: value === undefined ? !getters.layoutSettings[propertyName] : value,
});
badgeSvc.addBadge(featureId);
};
const notEnoughSpace = (getters) => { const notEnoughSpace = (getters) => {
const layoutConstants = getters['layout/constants']; const layoutConstants = getters['layout/constants'];
const showGutter = getters['discussion/currentDiscussion']; const showGutter = getters['discussion/currentDiscussion'];
@ -132,6 +136,7 @@ export default {
}, },
}, },
getters: { getters: {
serverConf: getter('serverConf'),
workspaces: getter('workspaces'), // Not to be used, prefer workspace/workspacesById workspaces: getter('workspaces'), // Not to be used, prefer workspace/workspacesById
settings: getter('settings'), settings: getter('settings'),
computedSettings: (state, { settings }) => { computedSettings: (state, { settings }) => {
@ -204,8 +209,9 @@ export default {
gitlabTokensBySub: (state, { tokensByType }) => tokensByType.gitlab || {}, gitlabTokensBySub: (state, { tokensByType }) => tokensByType.gitlab || {},
wordpressTokensBySub: (state, { tokensByType }) => tokensByType.wordpress || {}, wordpressTokensBySub: (state, { tokensByType }) => tokensByType.wordpress || {},
zendeskTokensBySub: (state, { tokensByType }) => tokensByType.zendesk || {}, zendeskTokensBySub: (state, { tokensByType }) => tokensByType.zendesk || {},
badges: getter('badges'), badgeCreations: getter('badgeCreations'),
badgeTree: (state, { badges }) => features.map(feature => feature.toBadge(badges)), badgeTree: (state, { badgeCreations }) => features
.map(feature => feature.toBadge(badgeCreations)),
allBadges: (state, { badgeTree }) => { allBadges: (state, { badgeTree }) => {
const result = []; const result = [];
const processBadgeNodes = nodes => nodes.forEach((node) => { const processBadgeNodes = nodes => nodes.forEach((node) => {
@ -219,15 +225,16 @@ export default {
}, },
}, },
actions: { actions: {
setServerConf: setter('serverConf'),
setSettings: setter('settings'), setSettings: setter('settings'),
patchLocalSettings: patcher('localSettings'), patchLocalSettings: patcher('localSettings'),
patchLayoutSettings: patcher('layoutSettings'), patchLayoutSettings: patcher('layoutSettings'),
toggleNavigationBar: layoutSettingsToggler('showNavigationBar'), toggleNavigationBar: layoutSettingsToggler('showNavigationBar', 'toggleNavigationBar'),
toggleEditor: layoutSettingsToggler('showEditor'), toggleEditor: layoutSettingsToggler('showEditor', 'toggleEditor'),
toggleSidePreview: layoutSettingsToggler('showSidePreview'), toggleSidePreview: layoutSettingsToggler('showSidePreview', 'toggleSidePreview'),
toggleStatusBar: layoutSettingsToggler('showStatusBar'), toggleStatusBar: layoutSettingsToggler('showStatusBar', 'toggleStatusBar'),
toggleScrollSync: layoutSettingsToggler('scrollSync'), toggleScrollSync: layoutSettingsToggler('scrollSync', 'toggleScrollSync'),
toggleFocusMode: layoutSettingsToggler('focusMode'), toggleFocusMode: layoutSettingsToggler('focusMode', 'toggleFocusMode'),
toggleSideBar: ({ getters, dispatch, rootGetters }, value) => { toggleSideBar: ({ getters, dispatch, rootGetters }, value) => {
// Reset side bar // Reset side bar
dispatch('setSideBarPanel'); dispatch('setSideBarPanel');
@ -240,6 +247,7 @@ export default {
patch.showExplorer = false; patch.showExplorer = false;
} }
dispatch('patchLayoutSettings', patch); dispatch('patchLayoutSettings', patch);
badgeSvc.addBadge('toggleSideBar');
}, },
toggleExplorer: ({ getters, dispatch, rootGetters }, value) => { toggleExplorer: ({ getters, dispatch, rootGetters }, value) => {
// Close side bar if not enough space // Close side bar if not enough space
@ -250,6 +258,7 @@ export default {
patch.showSideBar = false; patch.showSideBar = false;
} }
dispatch('patchLayoutSettings', patch); dispatch('patchLayoutSettings', patch);
badgeSvc.addBadge('toggleExplorer');
}, },
setSideBarPanel: ({ dispatch }, value) => dispatch('patchLayoutSettings', { setSideBarPanel: ({ dispatch }, value) => dispatch('patchLayoutSettings', {
sideBarPanel: value === undefined ? 'menu' : value, sideBarPanel: value === undefined ? 'menu' : value,
@ -288,6 +297,6 @@ export default {
addGitlabToken: tokenAdder('gitlab'), addGitlabToken: tokenAdder('gitlab'),
addWordpressToken: tokenAdder('wordpress'), addWordpressToken: tokenAdder('wordpress'),
addZendeskToken: tokenAdder('zendesk'), addZendeskToken: tokenAdder('zendesk'),
patchBadges: patcher('badges'), patchBadgeCreations: patcher('badgeCreations'),
}, },
}; };

View File

@ -28,8 +28,6 @@ module.exports = {
'!**/node_modules/**', '!**/node_modules/**',
], ],
globals: { globals: {
GOOGLE_CLIENT_ID: 'GOOGLE_CLIENT_ID',
GITHUB_CLIENT_ID: 'GITHUB_CLIENT_ID',
NODE_ENV: 'production', NODE_ENV: 'production',
}, },
}; };

View File

@ -3,39 +3,45 @@ import store from '../../../../src/store';
import specUtils from '../specUtils'; import specUtils from '../specUtils';
describe('ButtonBar.vue', () => { describe('ButtonBar.vue', () => {
it('should toggle the navigation bar', () => specUtils.checkToggler( it('should toggle the navigation bar', async () => specUtils.checkToggler(
ButtonBar, ButtonBar,
wrapper => wrapper.find('.button-bar__button--navigation-bar-toggler').trigger('click'), wrapper => wrapper.find('.button-bar__button--navigation-bar-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showNavigationBar, () => store.getters['data/layoutSettings'].showNavigationBar,
'toggleNavigationBar',
)); ));
it('should toggle the side preview', () => specUtils.checkToggler( it('should toggle the side preview', async () => specUtils.checkToggler(
ButtonBar, ButtonBar,
wrapper => wrapper.find('.button-bar__button--side-preview-toggler').trigger('click'), wrapper => wrapper.find('.button-bar__button--side-preview-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showSidePreview, () => store.getters['data/layoutSettings'].showSidePreview,
'toggleSidePreview',
)); ));
it('should toggle the editor', () => specUtils.checkToggler( it('should toggle the editor', async () => specUtils.checkToggler(
ButtonBar, ButtonBar,
wrapper => wrapper.find('.button-bar__button--editor-toggler').trigger('click'), wrapper => wrapper.find('.button-bar__button--editor-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showEditor, () => store.getters['data/layoutSettings'].showEditor,
'toggleEditor',
)); ));
it('should toggle the focus mode', () => specUtils.checkToggler( it('should toggle the focus mode', async () => specUtils.checkToggler(
ButtonBar, ButtonBar,
wrapper => wrapper.find('.button-bar__button--focus-mode-toggler').trigger('click'), wrapper => wrapper.find('.button-bar__button--focus-mode-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].focusMode, () => store.getters['data/layoutSettings'].focusMode,
'toggleFocusMode',
)); ));
it('should toggle the scroll sync', () => specUtils.checkToggler( it('should toggle the scroll sync', async () => specUtils.checkToggler(
ButtonBar, ButtonBar,
wrapper => wrapper.find('.button-bar__button--scroll-sync-toggler').trigger('click'), wrapper => wrapper.find('.button-bar__button--scroll-sync-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].scrollSync, () => store.getters['data/layoutSettings'].scrollSync,
'toggleScrollSync',
)); ));
it('should toggle the status bar', () => specUtils.checkToggler( it('should toggle the status bar', async () => specUtils.checkToggler(
ButtonBar, ButtonBar,
wrapper => wrapper.find('.button-bar__button--status-bar-toggler').trigger('click'), wrapper => wrapper.find('.button-bar__button--status-bar-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showStatusBar, () => store.getters['data/layoutSettings'].showStatusBar,
'toggleStatusBar',
)); ));
}); });

View File

@ -14,7 +14,7 @@ const ensureNotExists = file => expect(store.getters.allItemsById).not.toHavePro
const refreshItem = item => store.getters.allItemsById[item.id]; const refreshItem = item => store.getters.allItemsById[item.id];
describe('Explorer.vue', () => { describe('Explorer.vue', () => {
it('should create new files in the root folder', () => { it('should create new file in the root folder', async () => {
expect(store.state.explorer.newChildNode.isNil).toBeTruthy(); expect(store.state.explorer.newChildNode.isNil).toBeTruthy();
const wrapper = mount(); const wrapper = mount();
wrapper.find('.side-title__button--new-file').trigger('click'); wrapper.find('.side-title__button--new-file').trigger('click');
@ -25,7 +25,7 @@ describe('Explorer.vue', () => {
}); });
}); });
it('should create new files in a folder', async () => { it('should create new file in a folder', async () => {
const folder = await workspaceSvc.storeItem({ type: 'folder' }); const folder = await workspaceSvc.storeItem({ type: 'folder' });
const wrapper = mount(); const wrapper = mount();
select(folder.id); select(folder.id);
@ -36,7 +36,7 @@ describe('Explorer.vue', () => {
}); });
}); });
it('should not create new files in the trash folder', () => { it('should not create new files in the trash folder', async () => {
const wrapper = mount(); const wrapper = mount();
select('trash'); select('trash');
wrapper.find('.side-title__button--new-file').trigger('click'); wrapper.find('.side-title__button--new-file').trigger('click');
@ -46,7 +46,7 @@ describe('Explorer.vue', () => {
}); });
}); });
it('should create new folders in the root folder', () => { it('should create new folders in the root folder', async () => {
expect(store.state.explorer.newChildNode.isNil).toBeTruthy(); expect(store.state.explorer.newChildNode.isNil).toBeTruthy();
const wrapper = mount(); const wrapper = mount();
wrapper.find('.side-title__button--new-folder').trigger('click'); wrapper.find('.side-title__button--new-folder').trigger('click');
@ -68,7 +68,7 @@ describe('Explorer.vue', () => {
}); });
}); });
it('should not create new folders in the trash folder', () => { it('should not create new folders in the trash folder', async () => {
const wrapper = mount(); const wrapper = mount();
select('trash'); select('trash');
wrapper.find('.side-title__button--new-folder').trigger('click'); wrapper.find('.side-title__button--new-folder').trigger('click');
@ -78,7 +78,7 @@ describe('Explorer.vue', () => {
}); });
}); });
it('should not create new folders in the temp folder', () => { it('should not create new folders in the temp folder', async () => {
const wrapper = mount(); const wrapper = mount();
select('temp'); select('temp');
wrapper.find('.side-title__button--new-folder').trigger('click'); wrapper.find('.side-title__button--new-folder').trigger('click');
@ -96,6 +96,7 @@ describe('Explorer.vue', () => {
wrapper.find('.side-title__button--delete').trigger('click'); wrapper.find('.side-title__button--delete').trigger('click');
ensureExists(file); ensureExists(file);
expect(refreshItem(file).parentId).toEqual('trash'); expect(refreshItem(file).parentId).toEqual('trash');
await specUtils.expectBadge('removeFile');
}); });
it('should not delete the trash folder', async () => { it('should not delete the trash folder', async () => {
@ -103,15 +104,17 @@ describe('Explorer.vue', () => {
select('trash'); select('trash');
wrapper.find('.side-title__button--delete').trigger('click'); wrapper.find('.side-title__button--delete').trigger('click');
await specUtils.resolveModal('trashDeletion'); await specUtils.resolveModal('trashDeletion');
await specUtils.expectBadge('removeFile', false);
}); });
it('should not delete files in the trash folder', async () => { it('should not delete file in the trash folder', async () => {
const file = await workspaceSvc.createFile({ parentId: 'trash' }, true); const file = await workspaceSvc.createFile({ parentId: 'trash' }, true);
const wrapper = mount(); const wrapper = mount();
select(file.id); select(file.id);
wrapper.find('.side-title__button--delete').trigger('click'); wrapper.find('.side-title__button--delete').trigger('click');
await specUtils.resolveModal('trashDeletion'); await specUtils.resolveModal('trashDeletion');
ensureExists(file); ensureExists(file);
await specUtils.expectBadge('removeFile', false);
}); });
it('should delete the temp folder after confirmation', async () => { it('should delete the temp folder after confirmation', async () => {
@ -121,9 +124,10 @@ describe('Explorer.vue', () => {
wrapper.find('.side-title__button--delete').trigger('click'); wrapper.find('.side-title__button--delete').trigger('click');
await specUtils.resolveModal('tempFolderDeletion'); await specUtils.resolveModal('tempFolderDeletion');
ensureNotExists(file); ensureNotExists(file);
await specUtils.expectBadge('removeFolder');
}); });
it('should delete temp files after confirmation', async () => { it('should delete temp file after confirmation', async () => {
const file = await workspaceSvc.createFile({ parentId: 'temp' }, true); const file = await workspaceSvc.createFile({ parentId: 'temp' }, true);
const wrapper = mount(); const wrapper = mount();
select(file.id); select(file.id);
@ -131,6 +135,7 @@ describe('Explorer.vue', () => {
ensureExists(file); ensureExists(file);
await specUtils.resolveModal('tempFileDeletion'); await specUtils.resolveModal('tempFileDeletion');
ensureNotExists(file); ensureNotExists(file);
await specUtils.expectBadge('removeFile');
}); });
it('should delete folder after confirmation', async () => { it('should delete folder after confirmation', async () => {
@ -144,9 +149,10 @@ describe('Explorer.vue', () => {
// Make sure file has been moved to Trash // Make sure file has been moved to Trash
ensureExists(file); ensureExists(file);
expect(refreshItem(file).parentId).toEqual('trash'); expect(refreshItem(file).parentId).toEqual('trash');
await specUtils.expectBadge('removeFolder');
}); });
it('should rename files', async () => { it('should rename file', async () => {
const file = await workspaceSvc.createFile({}, true); const file = await workspaceSvc.createFile({}, true);
const wrapper = mount(); const wrapper = mount();
select(file.id); select(file.id);
@ -154,7 +160,7 @@ describe('Explorer.vue', () => {
expect(store.getters['explorer/editingNode'].item.id).toEqual(file.id); expect(store.getters['explorer/editingNode'].item.id).toEqual(file.id);
}); });
it('should rename folders', async () => { it('should rename folder', async () => {
const folder = await workspaceSvc.storeItem({ type: 'folder' }); const folder = await workspaceSvc.storeItem({ type: 'folder' });
const wrapper = mount(); const wrapper = mount();
select(folder.id); select(folder.id);
@ -182,6 +188,7 @@ describe('Explorer.vue', () => {
Explorer, Explorer,
wrapper => wrapper.find('.side-title__button--close').trigger('click'), wrapper => wrapper.find('.side-title__button--close').trigger('click'),
() => store.getters['data/layoutSettings'].showExplorer, () => store.getters['data/layoutSettings'].showExplorer,
'toggleExplorer',
); );
}); });
}); });

View File

@ -49,15 +49,25 @@ const dragAndDrop = (sourceItem, targetItem) => {
describe('ExplorerNode.vue', () => { describe('ExplorerNode.vue', () => {
const modifiedName = 'Name'; const modifiedName = 'Name';
it('should open files on select after a timeout', async () => { it('should open file on select after a timeout', async () => {
const node = await makeFileNode(); const node = await makeFileNode();
mountAndSelect(node); mountAndSelect(node);
expect(store.getters['file/current'].id).not.toEqual(node.item.id); expect(store.getters['file/current'].id).not.toEqual(node.item.id);
await new Promise(resolve => setTimeout(resolve, 10)); await new Promise(resolve => setTimeout(resolve, 10));
expect(store.getters['file/current'].id).toEqual(node.item.id); expect(store.getters['file/current'].id).toEqual(node.item.id);
await specUtils.expectBadge('switchFile');
}); });
it('should open folders on select after a timeout', async () => { it('should not open already open file', async () => {
const node = await makeFileNode();
store.commit('file/setCurrentId', node.item.id);
mountAndSelect(node);
await new Promise(resolve => setTimeout(resolve, 10));
expect(store.getters['file/current'].id).toEqual(node.item.id);
await specUtils.expectBadge('switchFile', false);
});
it('should open folder on select after a timeout', async () => {
const node = await makeFolderNode(); const node = await makeFolderNode();
const wrapper = mountAndSelect(node); const wrapper = mountAndSelect(node);
expect(wrapper.classes()).not.toContain('explorer-node--open'); expect(wrapper.classes()).not.toContain('explorer-node--open');
@ -65,7 +75,7 @@ describe('ExplorerNode.vue', () => {
expect(wrapper.classes()).toContain('explorer-node--open'); expect(wrapper.classes()).toContain('explorer-node--open');
}); });
it('should open folders on new child', async () => { it('should open folder on new child', async () => {
const node = await makeFolderNode(); const node = await makeFolderNode();
const wrapper = mountAndSelect(node); const wrapper = mountAndSelect(node);
// Close the folder // Close the folder
@ -76,7 +86,7 @@ describe('ExplorerNode.vue', () => {
expect(wrapper.classes()).toContain('explorer-node--open'); expect(wrapper.classes()).toContain('explorer-node--open');
}); });
it('should create new files in a folder', async () => { it('should create new file in a folder', async () => {
const node = await makeFolderNode(); const node = await makeFolderNode();
const wrapper = mount(node); const wrapper = mount(node);
wrapper.trigger('contextmenu'); wrapper.trigger('contextmenu');
@ -91,9 +101,10 @@ describe('ExplorerNode.vue', () => {
parentId: node.item.id, parentId: node.item.id,
}); });
expect(wrapper.contains('.explorer-node__new-child')).toBe(false); expect(wrapper.contains('.explorer-node__new-child')).toBe(false);
await specUtils.expectBadge('createFile');
}); });
it('should cancel a file creation on escape', async () => { it('should cancel file creation on escape', async () => {
const node = await makeFolderNode(); const node = await makeFolderNode();
const wrapper = mount(node); const wrapper = mount(node);
wrapper.trigger('contextmenu'); wrapper.trigger('contextmenu');
@ -110,15 +121,16 @@ describe('ExplorerNode.vue', () => {
parentId: node.item.id, parentId: node.item.id,
}); });
expect(wrapper.contains('.explorer-node__new-child')).toBe(false); expect(wrapper.contains('.explorer-node__new-child')).toBe(false);
await specUtils.expectBadge('createFile', false);
}); });
it('should not create new files in a file', async () => { it('should not create new file in a file', async () => {
const node = await makeFileNode(); const node = await makeFileNode();
mount(node).trigger('contextmenu'); mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New file').disabled).toBe(true); expect(specUtils.getContextMenuItem('New file').disabled).toBe(true);
}); });
it('should not create new files in the trash folder', async () => { it('should not create new file in the trash folder', async () => {
const node = store.getters['explorer/nodeMap'].trash; const node = store.getters['explorer/nodeMap'].trash;
mount(node).trigger('contextmenu'); mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New file').disabled).toBe(true); expect(specUtils.getContextMenuItem('New file').disabled).toBe(true);
@ -139,9 +151,10 @@ describe('ExplorerNode.vue', () => {
parentId: node.item.id, parentId: node.item.id,
}); });
expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false); expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false);
await specUtils.expectBadge('createFolder');
}); });
it('should cancel a folder creation on escape', async () => { it('should cancel folder creation on escape', async () => {
const node = await makeFolderNode(); const node = await makeFolderNode();
const wrapper = mount(node); const wrapper = mount(node);
wrapper.trigger('contextmenu'); wrapper.trigger('contextmenu');
@ -158,27 +171,28 @@ describe('ExplorerNode.vue', () => {
parentId: node.item.id, parentId: node.item.id,
}); });
expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false); expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false);
await specUtils.expectBadge('createFolder', false);
}); });
it('should not create new folders in a file', async () => { it('should not create new folder in a file', async () => {
const node = await makeFileNode(); const node = await makeFileNode();
mount(node).trigger('contextmenu'); mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true); expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true);
}); });
it('should not create new folders in the trash folder', async () => { it('should not create new folder in the trash folder', async () => {
const node = store.getters['explorer/nodeMap'].trash; const node = store.getters['explorer/nodeMap'].trash;
mount(node).trigger('contextmenu'); mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true); expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true);
}); });
it('should not create new folders in the temp folder', async () => { it('should not create new folder in the temp folder', async () => {
const node = store.getters['explorer/nodeMap'].temp; const node = store.getters['explorer/nodeMap'].temp;
mount(node).trigger('contextmenu'); mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true); expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true);
}); });
it('should rename files', async () => { it('should rename file', async () => {
const node = await makeFileNode(); const node = await makeFileNode();
const wrapper = mount(node); const wrapper = mount(node);
wrapper.trigger('contextmenu'); wrapper.trigger('contextmenu');
@ -187,20 +201,10 @@ describe('ExplorerNode.vue', () => {
wrapper.setData({ editingValue: modifiedName }); wrapper.setData({ editingValue: modifiedName });
wrapper.find('.explorer-node__item-editor .text-input').trigger('blur'); wrapper.find('.explorer-node__item-editor .text-input').trigger('blur');
expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName); expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName);
await specUtils.expectBadge('renameFile');
}); });
it('should rename folders', async () => { it('should cancel rename file on escape', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('Rename');
expect(wrapper.contains('.explorer-node__item-editor')).toBe(true);
wrapper.setData({ editingValue: modifiedName });
wrapper.find('.explorer-node__item-editor .text-input').trigger('blur');
expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName);
});
it('should cancel rename on escape', async () => {
const node = await makeFileNode(); const node = await makeFileNode();
const wrapper = mount(node); const wrapper = mount(node);
wrapper.trigger('contextmenu'); wrapper.trigger('contextmenu');
@ -211,6 +215,33 @@ describe('ExplorerNode.vue', () => {
keyCode: 27, keyCode: 27,
}); });
expect(store.getters['explorer/selectedNode'].item.name).not.toEqual(modifiedName); expect(store.getters['explorer/selectedNode'].item.name).not.toEqual(modifiedName);
await specUtils.expectBadge('renameFile', false);
});
it('should rename folder', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('Rename');
expect(wrapper.contains('.explorer-node__item-editor')).toBe(true);
wrapper.setData({ editingValue: modifiedName });
wrapper.find('.explorer-node__item-editor .text-input').trigger('blur');
expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName);
await specUtils.expectBadge('renameFolder');
});
it('should cancel rename folder on escape', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('Rename');
expect(wrapper.contains('.explorer-node__item-editor')).toBe(true);
wrapper.setData({ editingValue: modifiedName });
wrapper.find('.explorer-node__item-editor .text-input').trigger('keydown', {
keyCode: 27,
});
expect(store.getters['explorer/selectedNode'].item.name).not.toEqual(modifiedName);
await specUtils.expectBadge('renameFolder', false);
}); });
it('should not rename the trash folder', async () => { it('should not rename the trash folder', async () => {
@ -225,23 +256,26 @@ describe('ExplorerNode.vue', () => {
expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true); expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true);
}); });
it('should move a file into a folder', async () => { it('should move file into a folder', async () => {
const sourceItem = await workspaceSvc.createFile({}, true); const sourceItem = await workspaceSvc.createFile({}, true);
const targetItem = await workspaceSvc.storeItem({ type: 'folder' }); const targetItem = await workspaceSvc.storeItem({ type: 'folder' });
dragAndDrop(sourceItem, targetItem); dragAndDrop(sourceItem, targetItem);
await specUtils.expectBadge('moveFile');
}); });
it('should move a folder into a folder', async () => { it('should move folder into a folder', async () => {
const sourceItem = await workspaceSvc.storeItem({ type: 'folder' }); const sourceItem = await workspaceSvc.storeItem({ type: 'folder' });
const targetItem = await workspaceSvc.storeItem({ type: 'folder' }); const targetItem = await workspaceSvc.storeItem({ type: 'folder' });
dragAndDrop(sourceItem, targetItem); dragAndDrop(sourceItem, targetItem);
await specUtils.expectBadge('moveFolder');
}); });
it('should move a file into a file parent folder', async () => { it('should move file into a file parent folder', async () => {
const targetItem = await workspaceSvc.storeItem({ type: 'folder' }); const targetItem = await workspaceSvc.storeItem({ type: 'folder' });
const file = await workspaceSvc.createFile({ parentId: targetItem.id }, true); const file = await workspaceSvc.createFile({ parentId: targetItem.id }, true);
const sourceItem = await workspaceSvc.createFile({}, true); const sourceItem = await workspaceSvc.createFile({}, true);
dragAndDrop(sourceItem, file); dragAndDrop(sourceItem, file);
await specUtils.expectBadge('moveFile');
}); });
it('should not move the trash folder', async () => { it('should not move the trash folder', async () => {
@ -256,14 +290,14 @@ describe('ExplorerNode.vue', () => {
expect(store.state.explorer.dragSourceId).not.toEqual('temp'); expect(store.state.explorer.dragSourceId).not.toEqual('temp');
}); });
it('should not move a file to the temp folder', async () => { it('should not move file to the temp folder', async () => {
const targetNode = store.getters['explorer/nodeMap'].temp; const targetNode = store.getters['explorer/nodeMap'].temp;
const wrapper = mount(targetNode); const wrapper = mount(targetNode);
wrapper.trigger('dragenter'); wrapper.trigger('dragenter');
expect(store.state.explorer.dragTargetId).not.toEqual('temp'); expect(store.state.explorer.dragTargetId).not.toEqual('temp');
}); });
it('should not move a file to a file in the temp folder', async () => { it('should not move file to a file in the temp folder', async () => {
const file = await workspaceSvc.createFile({ parentId: 'temp' }, true); const file = await workspaceSvc.createFile({ parentId: 'temp' }, true);
const targetNode = store.getters['explorer/nodeMap'][file.id]; const targetNode = store.getters['explorer/nodeMap'][file.id];
const wrapper = mount(targetNode); const wrapper = mount(targetNode);

View File

@ -3,15 +3,17 @@ import store from '../../../../src/store';
import specUtils from '../specUtils'; import specUtils from '../specUtils';
describe('NavigationBar.vue', () => { describe('NavigationBar.vue', () => {
it('should toggle the explorer', () => specUtils.checkToggler( it('should toggle the explorer', async () => specUtils.checkToggler(
NavigationBar, NavigationBar,
wrapper => wrapper.find('.navigation-bar__button--explorer-toggler').trigger('click'), wrapper => wrapper.find('.navigation-bar__button--explorer-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showExplorer, () => store.getters['data/layoutSettings'].showExplorer,
'toggleExplorer',
)); ));
it('should toggle the side bar', () => specUtils.checkToggler( it('should toggle the side bar', async () => specUtils.checkToggler(
NavigationBar, NavigationBar,
wrapper => wrapper.find('.navigation-bar__button--stackedit').trigger('click'), wrapper => wrapper.find('.navigation-bar__button--stackedit').trigger('click'),
() => store.getters['data/layoutSettings'].showSideBar, () => store.getters['data/layoutSettings'].showSideBar,
'toggleSideBar',
)); ));
}); });

View File

@ -25,12 +25,13 @@ beforeEach(() => {
}); });
export default { export default {
checkToggler(Component, toggler, checker) { async checkToggler(Component, toggler, checker, featureId) {
const wrapper = shallowMount(Component, { store }); const wrapper = shallowMount(Component, { store });
const valueBefore = checker(); const valueBefore = checker();
toggler(wrapper); toggler(wrapper);
const valueAfter = checker(); const valueAfter = checker();
expect(valueAfter).toEqual(!valueBefore); expect(valueAfter).toEqual(!valueBefore);
await this.expectBadge(featureId);
}, },
async resolveModal(type) { async resolveModal(type) {
const config = store.getters['modal/config']; const config = store.getters['modal/config'];
@ -47,5 +48,11 @@ export default {
expect(item).toBeTruthy(); expect(item).toBeTruthy();
store.state.contextMenu.resolve(item); store.state.contextMenu.resolve(item);
await new Promise(resolve => setTimeout(resolve, 1)); await new Promise(resolve => setTimeout(resolve, 1));
} },
async expectBadge(featureId, isEarned = true) {
await new Promise(resolve => setTimeout(resolve, 1));
expect(store.getters['data/allBadges'].filter(badge => badge.featureId === featureId)[0]).toMatchObject({
isEarned,
});
},
}; };