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:
parent
3f0597601e
commit
07d824faca
@ -16,9 +16,7 @@ module.exports = {
|
||||
],
|
||||
globals: {
|
||||
"NODE_ENV": false,
|
||||
"VERSION": false,
|
||||
"GOOGLE_CLIENT_ID": false,
|
||||
"GITHUB_CLIENT_ID": false
|
||||
"VERSION": false
|
||||
},
|
||||
// check if imports actually resolve
|
||||
'settings': {
|
||||
|
@ -19,9 +19,7 @@ module.exports = merge(baseWebpackConfig, {
|
||||
devtool: 'source-map',
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
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
|
||||
NODE_ENV: config.dev.env.NODE_ENV
|
||||
}),
|
||||
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
|
@ -2,7 +2,5 @@ var merge = require('webpack-merge')
|
||||
var prodEnv = require('./prod.env')
|
||||
|
||||
module.exports = merge(prodEnv, {
|
||||
NODE_ENV: '"development"',
|
||||
GOOGLE_CLIENT_ID: '"241271498917-c3loeet001r90q6u79q484bsh5clg4fr.apps.googleusercontent.com"',
|
||||
GITHUB_CLIENT_ID: '"cbf0cf25cfd026be23e1"'
|
||||
NODE_ENV: '"development"'
|
||||
})
|
||||
|
@ -24,7 +24,7 @@ module.exports = {
|
||||
dev: {
|
||||
env: require('./dev.env'),
|
||||
port: 8080,
|
||||
autoOpenBrowser: true,
|
||||
autoOpenBrowser: false,
|
||||
assetsSubDirectory: 'static',
|
||||
assetsPublicPath: '/',
|
||||
proxyTable: {},
|
||||
|
@ -1,5 +1,3 @@
|
||||
module.exports = {
|
||||
NODE_ENV: '"production"',
|
||||
GOOGLE_CLIENT_ID: '"241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com"',
|
||||
GITHUB_CLIENT_ID: '"30c1491057c9ad4dbd56"'
|
||||
NODE_ENV: '"production"'
|
||||
}
|
||||
|
37
server/conf.js
Normal file
37
server/conf.js
Normal 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,
|
||||
};
|
@ -1,5 +1,6 @@
|
||||
const qs = require('qs'); // eslint-disable-line import/no-extraneous-dependencies
|
||||
const request = require('request');
|
||||
const conf = require('./conf');
|
||||
|
||||
function githubToken(clientId, code) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -8,7 +9,7 @@ function githubToken(clientId, code) {
|
||||
url: 'https://github.com/login/oauth/access_token',
|
||||
qs: {
|
||||
client_id: clientId,
|
||||
client_secret: process.env.GITHUB_SECRET,
|
||||
client_secret: conf.values.githubClientSecret,
|
||||
code,
|
||||
},
|
||||
}, (err, res, body) => {
|
||||
|
@ -6,6 +6,7 @@ const user = require('./user');
|
||||
const github = require('./github');
|
||||
const pdf = require('./pdf');
|
||||
const pandoc = require('./pandoc');
|
||||
const conf = require('./conf');
|
||||
|
||||
const resolvePath = pathToResolve => path.join(__dirname, '..', pathToResolve);
|
||||
|
||||
@ -24,6 +25,7 @@ module.exports = (app, serveV4) => {
|
||||
}
|
||||
|
||||
app.get('/oauth2/githubToken', github.githubToken);
|
||||
app.get('/conf', (req, res) => res.send(conf.publicValues));
|
||||
app.get('/userInfo', user.userInfo);
|
||||
app.post('/pdfExport', pdf.generate);
|
||||
app.post('/pandocExport', pandoc.generate);
|
||||
|
@ -3,6 +3,7 @@ const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const tmp = require('tmp');
|
||||
const user = require('./user');
|
||||
const conf = require('./conf');
|
||||
|
||||
const outputFormats = {
|
||||
asciidoc: 'text/plain',
|
||||
@ -90,10 +91,9 @@ exports.generate = (req, res) => {
|
||||
reject(error);
|
||||
}
|
||||
|
||||
const binPath = process.env.PANDOC_PATH || 'pandoc';
|
||||
const format = outputFormat === 'pdf' ? 'latex' : outputFormat;
|
||||
params.push('-f', 'json', '-t', format, '-o', filePath);
|
||||
const pandoc = spawn(binPath, params, {
|
||||
const pandoc = spawn(conf.values.pandocPath, params, {
|
||||
stdio: [
|
||||
'pipe',
|
||||
'ignore',
|
||||
|
@ -3,6 +3,7 @@ const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const tmp = require('tmp');
|
||||
const user = require('./user');
|
||||
const conf = require('./conf');
|
||||
|
||||
/* eslint-disable no-var, prefer-arrow-callback, func-names */
|
||||
function waitForJavaScript() {
|
||||
@ -127,10 +128,9 @@ exports.generate = (req, res) => {
|
||||
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
|
||||
const binPath = process.env.WKHTMLTOPDF_PATH || 'wkhtmltopdf';
|
||||
params.push('--run-script', `${waitForJavaScript.toString()}waitForJavaScript()`);
|
||||
params.push('--window-status', 'done');
|
||||
const wkhtmltopdf = spawn(binPath, params.concat('-', filePath), {
|
||||
const wkhtmltopdf = spawn(conf.values.wkhtmltopdfPath, params.concat('-', filePath), {
|
||||
stdio: [
|
||||
'pipe',
|
||||
'ignore',
|
||||
|
@ -1,13 +1,8 @@
|
||||
const request = require('request');
|
||||
const AWS = require('aws-sdk');
|
||||
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 cb = (resolve, reject) => (err, res) => {
|
||||
@ -20,7 +15,7 @@ const cb = (resolve, reject) => (err, res) => {
|
||||
|
||||
exports.getUser = id => new Promise((resolve, reject) => {
|
||||
s3Client.getObject({
|
||||
Bucket: USER_BUCKET_NAME,
|
||||
Bucket: conf.values.userBucketName,
|
||||
Key: id,
|
||||
}, cb(resolve, reject));
|
||||
})
|
||||
@ -35,7 +30,7 @@ exports.getUser = id => new Promise((resolve, reject) => {
|
||||
|
||||
exports.putUser = (id, user) => new Promise((resolve, reject) => {
|
||||
s3Client.putObject({
|
||||
Bucket: USER_BUCKET_NAME,
|
||||
Bucket: conf.values.userBucketName,
|
||||
Key: id,
|
||||
Body: JSON.stringify(user),
|
||||
}, cb(resolve, reject));
|
||||
@ -43,13 +38,13 @@ exports.putUser = (id, user) => new Promise((resolve, reject) => {
|
||||
|
||||
exports.removeUser = id => new Promise((resolve, reject) => {
|
||||
s3Client.deleteObject({
|
||||
Bucket: USER_BUCKET_NAME,
|
||||
Bucket: conf.values.userBucketName,
|
||||
Key: id,
|
||||
}, cb(resolve, reject));
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
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
|
||||
}
|
||||
if (
|
||||
req.body.receiver_email !== PAYPAL_RECEIVER_EMAIL ||
|
||||
req.body.receiver_email !== conf.values.paypalReceiverEmail ||
|
||||
req.body.payment_status !== 'Completed' ||
|
||||
req.body.mc_currency !== 'USD' ||
|
||||
(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
|
||||
req.body.cmd = '_notify-validate';
|
||||
return new Promise((resolve, reject) => request.post({
|
||||
uri: PAYPAL_URI,
|
||||
uri: conf.values.paypalUri,
|
||||
form: req.body,
|
||||
}, (err, response, body) => {
|
||||
if (err) {
|
||||
|
@ -156,7 +156,7 @@ export default {
|
||||
...sourceNode.item,
|
||||
parentId: targetNode.item.id,
|
||||
});
|
||||
badgeSvc.addBadge('moveFiles');
|
||||
badgeSvc.addBadge(sourceNode.isFolder ? 'moveFolder' : 'moveFile');
|
||||
}
|
||||
},
|
||||
async onContextMenu(evt) {
|
||||
|
@ -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__icon flex flex--column flex--center">
|
||||
<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>
|
||||
</div>
|
||||
<div class="notification__content">
|
||||
|
@ -175,8 +175,9 @@ export default {
|
||||
|
||||
p {
|
||||
margin: 10px 15px;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.67;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -84,25 +84,25 @@
|
||||
Print
|
||||
</menu-entry>
|
||||
<hr>
|
||||
<menu-entry @click.native="settings">
|
||||
<icon-settings slot="icon"></icon-settings>
|
||||
<div>Settings</div>
|
||||
<span>Tweak application and keyboard shortcuts.</span>
|
||||
<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 @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 @click.native="templates">
|
||||
<icon-code-braces slot="icon"></icon-code-braces>
|
||||
<div><div class="menu-entry__label menu-entry__label--count">{{templateCount}}</div> Templates</div>
|
||||
<span>Configure Handlebars templates for your exports.</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> User accounts</div>
|
||||
<span>Manage access to your external accounts.</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 @click.native="settings">
|
||||
<icon-settings slot="icon"></icon-settings>
|
||||
<div>Settings</div>
|
||||
<span>Tweak application and keyboard shortcuts.</span>
|
||||
</menu-entry>
|
||||
<hr>
|
||||
<menu-entry @click.native="setPanel('workspaceBackups')">
|
||||
@ -111,10 +111,8 @@
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="reset">
|
||||
<icon-logout slot="icon"></icon-logout>
|
||||
<div>Reset application</div>
|
||||
<span>Sign out and clean all workspace data.</span>
|
||||
Reset application
|
||||
</menu-entry>
|
||||
<hr>
|
||||
<menu-entry @click.native="about">
|
||||
<icon-help-circle slot="icon"></icon-help-circle>
|
||||
About StackEdit
|
||||
@ -218,7 +216,7 @@ export default {
|
||||
async reset() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'reset');
|
||||
window.location.href = '#reset=true';
|
||||
localStorage.setItem('resetStackEdit', '1');
|
||||
window.location.reload();
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
|
@ -4,9 +4,6 @@
|
||||
<p>{{currentFileName}} can't be published as it's a temporary file.</p>
|
||||
</div>
|
||||
<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">
|
||||
<p>{{currentFileName}} is already published.</p>
|
||||
<menu-entry @click.native="requestPublish">
|
||||
@ -20,6 +17,9 @@
|
||||
<span>Manage publication locations for {{currentFileName}}.</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div class="side-bar__info" v-else-if="noToken">
|
||||
<p>You have to link an account to start publishing files.</p>
|
||||
</div>
|
||||
<hr>
|
||||
<div v-for="token in bloggerTokens" :key="'blogger-' + token.sub">
|
||||
<menu-entry @click.native="publishBlogger(token)">
|
||||
@ -181,8 +181,13 @@ export default {
|
||||
return tokensToArray(store.getters['data/zendeskTokensBySub']);
|
||||
},
|
||||
noToken() {
|
||||
return Object.values(store.getters['data/tokensByType'])
|
||||
.every(tokens => !Object.keys(tokens).length);
|
||||
return !this.bloggerTokens.length
|
||||
&& !this.dropboxTokens.length
|
||||
&& !this.githubTokens.length
|
||||
&& !this.gitlabTokens.length
|
||||
&& !this.googleDriveTokens.length
|
||||
&& !this.wordpressTokens.length
|
||||
&& !this.zendeskTokens.length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -4,9 +4,6 @@
|
||||
<p>{{currentFileName}} can't be synced as it's a temporary file.</p>
|
||||
</div>
|
||||
<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">
|
||||
<p>{{currentFileName}} is already synchronized.</p>
|
||||
<menu-entry @click.native="requestSync">
|
||||
@ -20,6 +17,9 @@
|
||||
<span>Manage synchronized locations for {{currentFileName}}.</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div class="side-bar__info" v-else-if="noToken">
|
||||
<p>You have to link an account to start syncing files.</p>
|
||||
</div>
|
||||
<hr>
|
||||
<div v-for="token in dropboxTokens" :key="token.sub">
|
||||
<menu-entry @click.native="openDropbox(token)">
|
||||
|
@ -17,15 +17,19 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FileSaver from 'file-saver';
|
||||
import MenuEntry from './common/MenuEntry';
|
||||
import utils from '../../services/utils';
|
||||
import store from '../../store';
|
||||
import backupSvc from '../../services/backupSvc';
|
||||
import localDbSvc from '../../services/localDbSvc';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuEntry,
|
||||
},
|
||||
computed: {
|
||||
workspaceId: () => store.getters['workspace/currentWorkspace'].id,
|
||||
},
|
||||
methods: {
|
||||
onImportBackup(evt) {
|
||||
const file = evt.target.files[0];
|
||||
@ -44,11 +48,16 @@ export default {
|
||||
}
|
||||
},
|
||||
exportWorkspace() {
|
||||
window.location.href = utils.addQueryParams('app', {
|
||||
...utils.queryParams,
|
||||
exportWorkspace: true,
|
||||
}, true);
|
||||
window.location.reload();
|
||||
const allItemsById = {};
|
||||
localDbSvc.getWorkspaceItems(this.workspaceId, (item) => {
|
||||
allItemsById[item.id] = item;
|
||||
}, () => {
|
||||
const backup = JSON.stringify(allItemsById);
|
||||
const blob = new Blob([backup], {
|
||||
type: 'text/plain;charset=utf-8',
|
||||
});
|
||||
FileSaver.saveAs(blob, 'StackEdit workspace.json');
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<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">
|
||||
<menu-entry :href="workspace.url" target="_blank">
|
||||
<icon-provider slot="icon" :provider-id="workspace.providerId"></icon-provider>
|
||||
@ -23,10 +29,6 @@
|
||||
<icon-provider slot="icon" provider-id="googleDriveWorkspace"></icon-provider>
|
||||
<span>Add a <b>Google Drive</b> workspace</span>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
@ -22,15 +22,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="account-entry__row">
|
||||
<span v-if="entry.userId">
|
||||
<span class="account-entry__field" v-if="entry.userId">
|
||||
<b>User ID:</b>
|
||||
{{entry.userId}}
|
||||
</span>
|
||||
<span v-if="entry.url">
|
||||
<span class="account-entry__field" v-if="entry.url">
|
||||
<b>URL:</b>
|
||||
{{entry.url}}
|
||||
</span>
|
||||
<span v-if="entry.scopes">
|
||||
<span class="account-entry__field" v-if="entry.scopes">
|
||||
<b>Scopes:</b>
|
||||
{{entry.scopes.join(', ')}}
|
||||
</span>
|
||||
@ -230,8 +230,12 @@ $button-size: 30px;
|
||||
|
||||
.account-entry__row {
|
||||
border-top: 1px solid $hr-color;
|
||||
opacity: 0.5;
|
||||
font-size: 0.67em;
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
|
||||
.account-entry__field {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.account-entry__icon {
|
||||
|
@ -6,17 +6,18 @@
|
||||
</div>
|
||||
<p v-if="badgeCount > 1">{{badgeCount}} badges 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">
|
||||
<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>
|
||||
<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">— {{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">
|
||||
<icon-seal></icon-seal>
|
||||
<icon-seal class="badge-entry__icon" :class="{'badge-entry__icon--earned': child.isEarned}"></icon-seal>
|
||||
<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">— {{child.description}}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -32,6 +33,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import store from '../../store';
|
||||
@ -40,6 +42,9 @@ export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
},
|
||||
data: () => ({
|
||||
shown: {},
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
@ -54,6 +59,11 @@ export default {
|
||||
return store.getters['data/allBadges'].length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
show(featureId) {
|
||||
Vue.set(this.shown, featureId, true);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -76,19 +86,24 @@ export default {
|
||||
|
||||
.badge-entry {
|
||||
font-size: 0.8em;
|
||||
margin: 0.75rem 0 0;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
.badge-entry__icon {
|
||||
width: 1.67em;
|
||||
height: 1.67em;
|
||||
margin-right: 0.25em;
|
||||
opacity: 0.33;
|
||||
opacity: 0.3;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.badge-entry__icon--some-earned {
|
||||
opacity: 0.5;
|
||||
color: goldenrod;
|
||||
}
|
||||
|
||||
.badge-entry--earned svg {
|
||||
.badge-entry__icon--earned {
|
||||
opacity: 1;
|
||||
color: goldenrod;
|
||||
}
|
||||
@ -100,10 +115,10 @@ export default {
|
||||
.badge-entry__name {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.badge-entry--earned & {
|
||||
.badge-entry__name--earned {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -4,7 +4,7 @@
|
||||
<div class="modal__image">
|
||||
<icon-database></icon-database>
|
||||
</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="flex flex--column">
|
||||
<div class="workspace-entry__header flex flex--row flex--align-center">
|
||||
@ -48,10 +48,12 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="workspace-entry__offline" v-if="availableOffline[id]">
|
||||
available offline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__info">
|
||||
<b>ProTip:</b> Workspaces are accessible offline, try it!
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
@ -61,11 +63,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import workspaceSvc from '../../services/workspaceSvc';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
import localDbSvc from '../../services/localDbSvc';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -74,6 +78,7 @@ export default {
|
||||
data: () => ({
|
||||
editedId: null,
|
||||
editingName: '',
|
||||
availableOffline: {},
|
||||
}),
|
||||
computed: {
|
||||
...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>
|
||||
|
||||
@ -208,4 +221,13 @@ $small-button-size: 22px;
|
||||
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>
|
||||
|
@ -19,7 +19,8 @@ export default {
|
||||
'settings',
|
||||
'layoutSettings',
|
||||
'tokens',
|
||||
'badges',
|
||||
'badgeCreations',
|
||||
'serverConf',
|
||||
],
|
||||
textMaxLength: 250000,
|
||||
defaultName: 'Untitled',
|
||||
|
@ -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 {
|
||||
constructor(id, badgeName, description, children = null) {
|
||||
this.id = id;
|
||||
@ -16,13 +6,20 @@ class Feature {
|
||||
this.children = children;
|
||||
}
|
||||
|
||||
toBadge(earnings) {
|
||||
toBadge(badgeCreations) {
|
||||
const children = this.children
|
||||
? this.children.map(child => child.toBadge(earnings))
|
||||
? this.children.map(child => child.toBadge(badgeCreations))
|
||||
: null;
|
||||
return new Badge(this.id, this.badgeName, this.description, children, children
|
||||
return {
|
||||
featureId: this.id,
|
||||
name: this.badgeName,
|
||||
description: this.description,
|
||||
children,
|
||||
isEarned: children
|
||||
? children.every(child => child.isEarned)
|
||||
: !!earnings[this.id]);
|
||||
: !!badgeCreations[this.id],
|
||||
hasSomeEarned: children && children.some(child => child.isEarned),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +39,16 @@ export default [
|
||||
'Renamer',
|
||||
'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(
|
||||
@ -65,9 +72,14 @@ export default [
|
||||
'Use the file explorer to create a new folder in your workspace.',
|
||||
),
|
||||
new Feature(
|
||||
'moveFiles',
|
||||
'moveFile',
|
||||
'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(
|
||||
'renameFile',
|
||||
@ -80,15 +92,57 @@ export default [
|
||||
'Use the file explorer to rename a folder in your workspace.',
|
||||
),
|
||||
new Feature(
|
||||
'removeFiles',
|
||||
'removeFile',
|
||||
'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(
|
||||
'signIn',
|
||||
'Logged in',
|
||||
'Signed in',
|
||||
'Sign in with Google, sync your main workspace and unlock functionalities.',
|
||||
[
|
||||
new Feature(
|
||||
@ -143,7 +197,7 @@ export default [
|
||||
new Feature(
|
||||
'manageAccounts',
|
||||
'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(
|
||||
'addBloggerAccount',
|
||||
@ -188,7 +242,7 @@ export default [
|
||||
new Feature(
|
||||
'removeAccount',
|
||||
'Revoker',
|
||||
'Use the "User accounts" dialog to remove access to an external account.',
|
||||
'Use the "Accounts" dialog to remove access to an external account.',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -13,7 +13,7 @@ The file explorer is accessible using the button in left corner of the navigatio
|
||||
|
||||
## 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
|
||||
|
||||
|
@ -16,7 +16,7 @@ const showInfo = () => {
|
||||
|
||||
export default {
|
||||
addBadge(featureId) {
|
||||
if (!store.getters['data/badges'][featureId]) {
|
||||
if (!store.getters['data/badgeCreations'][featureId]) {
|
||||
if (!lastEarnedFeatureIds) {
|
||||
const earnedFeatureIds = store.getters['data/allBadges']
|
||||
.filter(badge => badge.isEarned)
|
||||
@ -24,7 +24,7 @@ export default {
|
||||
lastEarnedFeatureIds = new Set(earnedFeatureIds);
|
||||
}
|
||||
|
||||
store.dispatch('data/patchBadges', {
|
||||
store.dispatch('data/patchBadgeCreations', {
|
||||
[featureId]: {
|
||||
created: Date.now(),
|
||||
},
|
||||
|
@ -62,7 +62,6 @@ export default {
|
||||
} else {
|
||||
workspaceSvc.deleteFile(id);
|
||||
}
|
||||
badgeSvc.addBadge('removeFiles');
|
||||
};
|
||||
|
||||
if (selectedNode === store.getters['explorer/selectedNode']) {
|
||||
@ -78,8 +77,10 @@ export default {
|
||||
store.commit('folder/deleteItem', folderNode.item.id);
|
||||
};
|
||||
recursiveDelete(selectedNode);
|
||||
badgeSvc.addBadge('removeFolder');
|
||||
} else {
|
||||
deleteFile(selectedNode.item.id);
|
||||
badgeSvc.addBadge('removeFile');
|
||||
}
|
||||
if (doClose) {
|
||||
// Close the current file by opening the last opened, not deleted one
|
||||
|
@ -1,23 +1,23 @@
|
||||
import FileSaver from 'file-saver';
|
||||
import utils from './utils';
|
||||
import store from '../store';
|
||||
import welcomeFile from '../data/welcomeFile.md';
|
||||
import workspaceSvc from './workspaceSvc';
|
||||
import constants from '../data/constants';
|
||||
|
||||
const deleteMarkerMaxAge = 1000;
|
||||
const dbVersion = 1;
|
||||
const dbStoreName = 'objects';
|
||||
const { exportWorkspace } = utils.queryParams;
|
||||
const { silent } = utils.queryParams;
|
||||
const resetApp = utils.queryParams.reset;
|
||||
const deleteMarkerMaxAge = 1000;
|
||||
const resetApp = localStorage.getItem('resetStackEdit');
|
||||
if (resetApp) {
|
||||
localStorage.removeItem('resetStackEdit');
|
||||
}
|
||||
|
||||
class Connection {
|
||||
constructor() {
|
||||
constructor(workspaceId = store.getters['workspace/currentWorkspace'].id) {
|
||||
this.getTxCbs = [];
|
||||
|
||||
// Make the DB name
|
||||
const workspaceId = store.getters['workspace/currentWorkspace'].id;
|
||||
this.dbName = utils.getDbName(workspaceId);
|
||||
|
||||
// Init connection
|
||||
@ -264,7 +264,7 @@ const localDbSvc = {
|
||||
// DB item is different from the corresponding store item
|
||||
this.hashMap[dbItem.type][dbItem.id] = dbItem.hash;
|
||||
// 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
|
||||
dbItem.tx = undefined;
|
||||
store.commit(`${dbItem.type}/setItem`, dbItem);
|
||||
@ -327,7 +327,7 @@ const localDbSvc = {
|
||||
* Create the connection and start syncing.
|
||||
*/
|
||||
async init() {
|
||||
// Reset the app if reset flag was passed
|
||||
// Reset the app if the reset flag was passed
|
||||
if (resetApp) {
|
||||
await Promise.all(Object.keys(store.getters['workspace/workspacesById'])
|
||||
.map(workspaceId => workspaceSvc.removeWorkspace(workspaceId)));
|
||||
@ -344,16 +344,6 @@ const localDbSvc = {
|
||||
// Load the DB
|
||||
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
|
||||
// to make the changes available to reloading workspace tabs.
|
||||
store.watch(
|
||||
@ -438,6 +428,27 @@ const localDbSvc = {
|
||||
{ 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}`)
|
||||
|
@ -8,7 +8,10 @@ const silentAuthorizeTimeout = 15 * 1000; // 15 secondes (which will be reattemp
|
||||
const networkTimeout = 30 * 1000; // 30 sec
|
||||
let isConnectionDown = false;
|
||||
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) {
|
||||
const pairs = xhr.getAllResponseHeaders().trim().split('\n');
|
||||
@ -31,21 +34,20 @@ function isRetriable(err) {
|
||||
}
|
||||
|
||||
export default {
|
||||
init() {
|
||||
async init() {
|
||||
// Keep track of the last user activity
|
||||
this.lastActivity = 0;
|
||||
const setLastActivity = () => {
|
||||
this.lastActivity = Date.now();
|
||||
lastActivity = Date.now();
|
||||
};
|
||||
window.document.addEventListener('mousedown', setLastActivity);
|
||||
window.document.addEventListener('keydown', setLastActivity);
|
||||
window.document.addEventListener('touchstart', setLastActivity);
|
||||
|
||||
// Keep track of the last window focus
|
||||
this.lastFocus = 0;
|
||||
lastFocus = 0;
|
||||
const setLastFocus = () => {
|
||||
this.lastFocus = Date.now();
|
||||
localStorage.setItem(store.getters['workspace/lastFocusKey'], this.lastFocus);
|
||||
lastFocus = Date.now();
|
||||
localStorage.setItem(store.getters['workspace/lastFocusKey'], lastFocus);
|
||||
setLastActivity();
|
||||
};
|
||||
if (document.hasFocus()) {
|
||||
@ -53,7 +55,7 @@ export default {
|
||||
}
|
||||
window.addEventListener('focus', setLastFocus);
|
||||
|
||||
// Check browser is online periodically
|
||||
// Check that browser is online periodically
|
||||
const checkOffline = async () => {
|
||||
const isBrowserOffline = window.navigator.onLine === false;
|
||||
if (!isBrowserOffline
|
||||
@ -90,23 +92,42 @@ export default {
|
||||
store.dispatch('notification/error', 'You are offline.');
|
||||
} else {
|
||||
store.dispatch('notification/info', 'You are back online!');
|
||||
this.getServerConf();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
utils.setInterval(checkOffline, 1000);
|
||||
window.addEventListener('online', () => {
|
||||
isConnectionDown = false;
|
||||
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() {
|
||||
// We don't use state.workspace.lastFocus as it's not reactive
|
||||
const storedLastFocus = localStorage.getItem(store.getters['workspace/lastFocusKey']);
|
||||
return parseInt(storedLastFocus, 10) === this.lastFocus;
|
||||
return parseInt(storedLastFocus, 10) === lastFocus;
|
||||
},
|
||||
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) {
|
||||
if (!scriptLoadingPromises[url]) {
|
||||
@ -217,15 +238,15 @@ export default {
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
async request(configParam, offlineCheck = false) {
|
||||
async request(config, offlineCheck = false) {
|
||||
let retryAfter = 500; // 500 ms
|
||||
const maxRetryAfter = 10 * 1000; // 10 sec
|
||||
const config = Object.assign({}, configParam);
|
||||
config.timeout = config.timeout || networkTimeout;
|
||||
config.headers = Object.assign({}, config.headers);
|
||||
if (config.body && typeof config.body === 'object') {
|
||||
config.body = JSON.stringify(config.body);
|
||||
config.headers['Content-Type'] = 'application/json';
|
||||
const sanitizedConfig = Object.assign({}, config);
|
||||
sanitizedConfig.timeout = sanitizedConfig.timeout || networkTimeout;
|
||||
sanitizedConfig.headers = Object.assign({}, sanitizedConfig.headers);
|
||||
if (sanitizedConfig.body && typeof sanitizedConfig.body === 'object') {
|
||||
sanitizedConfig.body = JSON.stringify(sanitizedConfig.body);
|
||||
sanitizedConfig.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const attempt = async () => {
|
||||
@ -236,7 +257,7 @@ export default {
|
||||
}
|
||||
|
||||
const xhr = new window.XMLHttpRequest();
|
||||
xhr.withCredentials = config.withCredentials || false;
|
||||
xhr.withCredentials = sanitizedConfig.withCredentials || false;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
xhr.abort();
|
||||
@ -247,7 +268,7 @@ export default {
|
||||
} else {
|
||||
reject(new Error('Network request timeout.'));
|
||||
}
|
||||
}, config.timeout);
|
||||
}, sanitizedConfig.timeout);
|
||||
|
||||
xhr.onload = () => {
|
||||
if (offlineCheck) {
|
||||
@ -257,9 +278,9 @@ export default {
|
||||
const result = {
|
||||
status: xhr.status,
|
||||
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 {
|
||||
result.body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
@ -284,17 +305,17 @@ export default {
|
||||
}
|
||||
};
|
||||
|
||||
const url = utils.addQueryParams(config.url, config.params);
|
||||
xhr.open(config.method || 'GET', url);
|
||||
Object.entries(config.headers).forEach(([key, value]) => {
|
||||
const url = utils.addQueryParams(sanitizedConfig.url, sanitizedConfig.params);
|
||||
xhr.open(sanitizedConfig.method || 'GET', url);
|
||||
Object.entries(sanitizedConfig.headers).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
xhr.setRequestHeader(key, `${value}`);
|
||||
}
|
||||
});
|
||||
if (config.blob) {
|
||||
if (sanitizedConfig.blob) {
|
||||
xhr.responseType = 'blob';
|
||||
}
|
||||
xhr.send(config.body || null);
|
||||
xhr.send(sanitizedConfig.body || null);
|
||||
});
|
||||
} catch (err) {
|
||||
// Try again later in case of retriable error
|
||||
|
@ -124,7 +124,11 @@ export default new Provider({
|
||||
};
|
||||
},
|
||||
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 => ({
|
||||
id: entry.rev,
|
||||
sub: `${dropboxHelper.subPrefix}:${(entry.sharing_info || {}).modified_by || token.sub}`,
|
||||
|
@ -5,9 +5,9 @@ import badgeSvc from '../../badgeSvc';
|
||||
|
||||
const getAppKey = (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)
|
||||
@ -60,6 +60,7 @@ export default {
|
||||
* https://www.dropbox.com/developers/documentation/http/documentation#users-get_current_account
|
||||
*/
|
||||
async startOauth2(fullAccess, sub = null, silent = false) {
|
||||
// Get an OAuth2 code
|
||||
const { accessToken } = await networkSvc.startOauth2(
|
||||
'https://www.dropbox.com/oauth2/authorize',
|
||||
{
|
||||
@ -146,14 +147,21 @@ export default {
|
||||
/**
|
||||
* https://www.dropbox.com/developers/documentation/http/documentation#list-revisions
|
||||
*/
|
||||
async listRevisions(token, fileId) {
|
||||
async listRevisions({
|
||||
token,
|
||||
path,
|
||||
fileId,
|
||||
}) {
|
||||
const res = await request(token, {
|
||||
method: 'POST',
|
||||
url: 'https://api.dropboxapi.com/2/files/list_revisions',
|
||||
body: {
|
||||
body: fileId ? {
|
||||
path: fileId,
|
||||
mode: 'id',
|
||||
limit: 100,
|
||||
} : {
|
||||
path,
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
return res.body.entries;
|
||||
|
@ -4,7 +4,6 @@ import store from '../../../store';
|
||||
import userSvc from '../../userSvc';
|
||||
import badgeSvc from '../../badgeSvc';
|
||||
|
||||
const clientId = GITHUB_CLIENT_ID;
|
||||
const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist'];
|
||||
|
||||
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/
|
||||
*/
|
||||
async startOauth2(scopes, sub = null, silent = false) {
|
||||
const clientId = store.getters['data/serverConf'].githubClientId;
|
||||
|
||||
// Get an OAuth2 code
|
||||
const { code } = await networkSvc.startOauth2(
|
||||
'https://github.com/login/oauth/authorize',
|
||||
{
|
||||
|
@ -51,6 +51,7 @@ export default {
|
||||
* https://docs.gitlab.com/ee/api/oauth2.html
|
||||
*/
|
||||
async startOauth2(serverUrl, applicationId, sub = null, silent = false) {
|
||||
// Get an OAuth2 code
|
||||
const { accessToken } = await networkSvc.startOauth2(
|
||||
`${serverUrl}/oauth/authorize`,
|
||||
{
|
||||
|
@ -4,8 +4,6 @@ import store from '../../../store';
|
||||
import userSvc from '../../userSvc';
|
||||
import badgeSvc from '../../badgeSvc';
|
||||
|
||||
const clientId = GOOGLE_CLIENT_ID;
|
||||
const apiKey = 'AIzaSyC_M4RA9pY6XmM9pmFxlT59UPMO7aHr9kk';
|
||||
const appsDomain = null;
|
||||
const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (tokens expire after 1h)
|
||||
|
||||
@ -21,6 +19,7 @@ const checkIdToken = (idToken) => {
|
||||
try {
|
||||
const token = idToken.split('.');
|
||||
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;
|
||||
} catch (e) {
|
||||
return false;
|
||||
@ -40,6 +39,7 @@ if (utils.queryParams.providerId === 'googleDrive') {
|
||||
* https://developers.google.com/people/api/rest/v1/people/get
|
||||
*/
|
||||
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 { body } = await networkSvc.request(sub === 'me' && token
|
||||
? {
|
||||
@ -111,6 +111,9 @@ export default {
|
||||
* https://developers.google.com/identity/protocols/OpenIDConnect
|
||||
*/
|
||||
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(
|
||||
'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
{
|
||||
@ -265,16 +268,6 @@ export default {
|
||||
badgeSvc.addBadge('addGooglePhotosAccount');
|
||||
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
|
||||
|
@ -2,7 +2,6 @@ import networkSvc from '../../networkSvc';
|
||||
import store from '../../../store';
|
||||
import badgeSvc from '../../badgeSvc';
|
||||
|
||||
const clientId = '23361';
|
||||
const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (WordPress tokens expire after 2 weeks)
|
||||
|
||||
const request = (token, options) => networkSvc.request({
|
||||
@ -19,6 +18,9 @@ export default {
|
||||
* https://developer.wordpress.com/docs/oauth2/
|
||||
*/
|
||||
async startOauth2(sub = null, silent = false) {
|
||||
const clientId = store.getters['data/serverConf'].wordpressClientId;
|
||||
|
||||
// Get an OAuth2 code
|
||||
const { accessToken, expiresIn } = await networkSvc.startOauth2(
|
||||
'https://public-api.wordpress.com/oauth2/authorize',
|
||||
{
|
||||
|
@ -17,6 +17,7 @@ export default {
|
||||
* https://support.zendesk.com/hc/en-us/articles/203663836-Using-OAuth-authentication-with-your-application
|
||||
*/
|
||||
async startOauth2(subdomain, clientId, sub = null, silent = false) {
|
||||
// Get an OAuth2 code
|
||||
const { accessToken } = await networkSvc.startOauth2(
|
||||
`https://${subdomain}.zendesk.com/oauth/authorizations/new`,
|
||||
{
|
||||
|
@ -728,7 +728,7 @@ const syncWorkspace = async (skipContents = false) => {
|
||||
if (workspace.id === 'main') {
|
||||
await syncDataItem('settings');
|
||||
await syncDataItem('workspaces');
|
||||
await syncDataItem('badges');
|
||||
await syncDataItem('badgeCreations');
|
||||
}
|
||||
await syncDataItem('templates');
|
||||
|
||||
|
@ -11,6 +11,7 @@ import styledHtmlWithTocTemplate from '../data/templates/styledHtmlWithTocTempla
|
||||
import jekyllSiteTemplate from '../data/templates/jekyllSiteTemplate.html';
|
||||
import constants from '../data/constants';
|
||||
import features from '../data/features';
|
||||
import badgeSvc from '../services/badgeSvc';
|
||||
|
||||
const itemTemplate = (id, data = {}) => ({
|
||||
id,
|
||||
@ -63,9 +64,12 @@ const patcher = id => ({ state, commit }, data) => {
|
||||
};
|
||||
|
||||
// For layoutSettings
|
||||
const layoutSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLayoutSettings', {
|
||||
const layoutSettingsToggler = (propertyName, featureId) => ({ getters, dispatch }, value) => {
|
||||
dispatch('patchLayoutSettings', {
|
||||
[propertyName]: value === undefined ? !getters.layoutSettings[propertyName] : value,
|
||||
});
|
||||
badgeSvc.addBadge(featureId);
|
||||
};
|
||||
const notEnoughSpace = (getters) => {
|
||||
const layoutConstants = getters['layout/constants'];
|
||||
const showGutter = getters['discussion/currentDiscussion'];
|
||||
@ -132,6 +136,7 @@ export default {
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
serverConf: getter('serverConf'),
|
||||
workspaces: getter('workspaces'), // Not to be used, prefer workspace/workspacesById
|
||||
settings: getter('settings'),
|
||||
computedSettings: (state, { settings }) => {
|
||||
@ -204,8 +209,9 @@ export default {
|
||||
gitlabTokensBySub: (state, { tokensByType }) => tokensByType.gitlab || {},
|
||||
wordpressTokensBySub: (state, { tokensByType }) => tokensByType.wordpress || {},
|
||||
zendeskTokensBySub: (state, { tokensByType }) => tokensByType.zendesk || {},
|
||||
badges: getter('badges'),
|
||||
badgeTree: (state, { badges }) => features.map(feature => feature.toBadge(badges)),
|
||||
badgeCreations: getter('badgeCreations'),
|
||||
badgeTree: (state, { badgeCreations }) => features
|
||||
.map(feature => feature.toBadge(badgeCreations)),
|
||||
allBadges: (state, { badgeTree }) => {
|
||||
const result = [];
|
||||
const processBadgeNodes = nodes => nodes.forEach((node) => {
|
||||
@ -219,15 +225,16 @@ export default {
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setServerConf: setter('serverConf'),
|
||||
setSettings: setter('settings'),
|
||||
patchLocalSettings: patcher('localSettings'),
|
||||
patchLayoutSettings: patcher('layoutSettings'),
|
||||
toggleNavigationBar: layoutSettingsToggler('showNavigationBar'),
|
||||
toggleEditor: layoutSettingsToggler('showEditor'),
|
||||
toggleSidePreview: layoutSettingsToggler('showSidePreview'),
|
||||
toggleStatusBar: layoutSettingsToggler('showStatusBar'),
|
||||
toggleScrollSync: layoutSettingsToggler('scrollSync'),
|
||||
toggleFocusMode: layoutSettingsToggler('focusMode'),
|
||||
toggleNavigationBar: layoutSettingsToggler('showNavigationBar', 'toggleNavigationBar'),
|
||||
toggleEditor: layoutSettingsToggler('showEditor', 'toggleEditor'),
|
||||
toggleSidePreview: layoutSettingsToggler('showSidePreview', 'toggleSidePreview'),
|
||||
toggleStatusBar: layoutSettingsToggler('showStatusBar', 'toggleStatusBar'),
|
||||
toggleScrollSync: layoutSettingsToggler('scrollSync', 'toggleScrollSync'),
|
||||
toggleFocusMode: layoutSettingsToggler('focusMode', 'toggleFocusMode'),
|
||||
toggleSideBar: ({ getters, dispatch, rootGetters }, value) => {
|
||||
// Reset side bar
|
||||
dispatch('setSideBarPanel');
|
||||
@ -240,6 +247,7 @@ export default {
|
||||
patch.showExplorer = false;
|
||||
}
|
||||
dispatch('patchLayoutSettings', patch);
|
||||
badgeSvc.addBadge('toggleSideBar');
|
||||
},
|
||||
toggleExplorer: ({ getters, dispatch, rootGetters }, value) => {
|
||||
// Close side bar if not enough space
|
||||
@ -250,6 +258,7 @@ export default {
|
||||
patch.showSideBar = false;
|
||||
}
|
||||
dispatch('patchLayoutSettings', patch);
|
||||
badgeSvc.addBadge('toggleExplorer');
|
||||
},
|
||||
setSideBarPanel: ({ dispatch }, value) => dispatch('patchLayoutSettings', {
|
||||
sideBarPanel: value === undefined ? 'menu' : value,
|
||||
@ -288,6 +297,6 @@ export default {
|
||||
addGitlabToken: tokenAdder('gitlab'),
|
||||
addWordpressToken: tokenAdder('wordpress'),
|
||||
addZendeskToken: tokenAdder('zendesk'),
|
||||
patchBadges: patcher('badges'),
|
||||
patchBadgeCreations: patcher('badgeCreations'),
|
||||
},
|
||||
};
|
||||
|
@ -28,8 +28,6 @@ module.exports = {
|
||||
'!**/node_modules/**',
|
||||
],
|
||||
globals: {
|
||||
GOOGLE_CLIENT_ID: 'GOOGLE_CLIENT_ID',
|
||||
GITHUB_CLIENT_ID: 'GITHUB_CLIENT_ID',
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
};
|
||||
|
@ -3,39 +3,45 @@ import store from '../../../../src/store';
|
||||
import specUtils from '../specUtils';
|
||||
|
||||
describe('ButtonBar.vue', () => {
|
||||
it('should toggle the navigation bar', () => specUtils.checkToggler(
|
||||
it('should toggle the navigation bar', async () => specUtils.checkToggler(
|
||||
ButtonBar,
|
||||
wrapper => wrapper.find('.button-bar__button--navigation-bar-toggler').trigger('click'),
|
||||
() => store.getters['data/layoutSettings'].showNavigationBar,
|
||||
'toggleNavigationBar',
|
||||
));
|
||||
|
||||
it('should toggle the side preview', () => specUtils.checkToggler(
|
||||
it('should toggle the side preview', async () => specUtils.checkToggler(
|
||||
ButtonBar,
|
||||
wrapper => wrapper.find('.button-bar__button--side-preview-toggler').trigger('click'),
|
||||
() => store.getters['data/layoutSettings'].showSidePreview,
|
||||
'toggleSidePreview',
|
||||
));
|
||||
|
||||
it('should toggle the editor', () => specUtils.checkToggler(
|
||||
it('should toggle the editor', async () => specUtils.checkToggler(
|
||||
ButtonBar,
|
||||
wrapper => wrapper.find('.button-bar__button--editor-toggler').trigger('click'),
|
||||
() => store.getters['data/layoutSettings'].showEditor,
|
||||
'toggleEditor',
|
||||
));
|
||||
|
||||
it('should toggle the focus mode', () => specUtils.checkToggler(
|
||||
it('should toggle the focus mode', async () => specUtils.checkToggler(
|
||||
ButtonBar,
|
||||
wrapper => wrapper.find('.button-bar__button--focus-mode-toggler').trigger('click'),
|
||||
() => store.getters['data/layoutSettings'].focusMode,
|
||||
'toggleFocusMode',
|
||||
));
|
||||
|
||||
it('should toggle the scroll sync', () => specUtils.checkToggler(
|
||||
it('should toggle the scroll sync', async () => specUtils.checkToggler(
|
||||
ButtonBar,
|
||||
wrapper => wrapper.find('.button-bar__button--scroll-sync-toggler').trigger('click'),
|
||||
() => store.getters['data/layoutSettings'].scrollSync,
|
||||
'toggleScrollSync',
|
||||
));
|
||||
|
||||
it('should toggle the status bar', () => specUtils.checkToggler(
|
||||
it('should toggle the status bar', async () => specUtils.checkToggler(
|
||||
ButtonBar,
|
||||
wrapper => wrapper.find('.button-bar__button--status-bar-toggler').trigger('click'),
|
||||
() => store.getters['data/layoutSettings'].showStatusBar,
|
||||
'toggleStatusBar',
|
||||
));
|
||||
});
|
||||
|
@ -14,7 +14,7 @@ const ensureNotExists = file => expect(store.getters.allItemsById).not.toHavePro
|
||||
const refreshItem = item => store.getters.allItemsById[item.id];
|
||||
|
||||
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();
|
||||
const wrapper = mount();
|
||||
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 wrapper = mount();
|
||||
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();
|
||||
select('trash');
|
||||
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();
|
||||
const wrapper = mount();
|
||||
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();
|
||||
select('trash');
|
||||
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();
|
||||
select('temp');
|
||||
wrapper.find('.side-title__button--new-folder').trigger('click');
|
||||
@ -96,6 +96,7 @@ describe('Explorer.vue', () => {
|
||||
wrapper.find('.side-title__button--delete').trigger('click');
|
||||
ensureExists(file);
|
||||
expect(refreshItem(file).parentId).toEqual('trash');
|
||||
await specUtils.expectBadge('removeFile');
|
||||
});
|
||||
|
||||
it('should not delete the trash folder', async () => {
|
||||
@ -103,15 +104,17 @@ describe('Explorer.vue', () => {
|
||||
select('trash');
|
||||
wrapper.find('.side-title__button--delete').trigger('click');
|
||||
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 wrapper = mount();
|
||||
select(file.id);
|
||||
wrapper.find('.side-title__button--delete').trigger('click');
|
||||
await specUtils.resolveModal('trashDeletion');
|
||||
ensureExists(file);
|
||||
await specUtils.expectBadge('removeFile', false);
|
||||
});
|
||||
|
||||
it('should delete the temp folder after confirmation', async () => {
|
||||
@ -121,9 +124,10 @@ describe('Explorer.vue', () => {
|
||||
wrapper.find('.side-title__button--delete').trigger('click');
|
||||
await specUtils.resolveModal('tempFolderDeletion');
|
||||
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 wrapper = mount();
|
||||
select(file.id);
|
||||
@ -131,6 +135,7 @@ describe('Explorer.vue', () => {
|
||||
ensureExists(file);
|
||||
await specUtils.resolveModal('tempFileDeletion');
|
||||
ensureNotExists(file);
|
||||
await specUtils.expectBadge('removeFile');
|
||||
});
|
||||
|
||||
it('should delete folder after confirmation', async () => {
|
||||
@ -144,9 +149,10 @@ describe('Explorer.vue', () => {
|
||||
// Make sure file has been moved to Trash
|
||||
ensureExists(file);
|
||||
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 wrapper = mount();
|
||||
select(file.id);
|
||||
@ -154,7 +160,7 @@ describe('Explorer.vue', () => {
|
||||
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 wrapper = mount();
|
||||
select(folder.id);
|
||||
@ -182,6 +188,7 @@ describe('Explorer.vue', () => {
|
||||
Explorer,
|
||||
wrapper => wrapper.find('.side-title__button--close').trigger('click'),
|
||||
() => store.getters['data/layoutSettings'].showExplorer,
|
||||
'toggleExplorer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -49,15 +49,25 @@ const dragAndDrop = (sourceItem, targetItem) => {
|
||||
describe('ExplorerNode.vue', () => {
|
||||
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();
|
||||
mountAndSelect(node);
|
||||
expect(store.getters['file/current'].id).not.toEqual(node.item.id);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
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 wrapper = mountAndSelect(node);
|
||||
expect(wrapper.classes()).not.toContain('explorer-node--open');
|
||||
@ -65,7 +75,7 @@ describe('ExplorerNode.vue', () => {
|
||||
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 wrapper = mountAndSelect(node);
|
||||
// Close the folder
|
||||
@ -76,7 +86,7 @@ describe('ExplorerNode.vue', () => {
|
||||
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 wrapper = mount(node);
|
||||
wrapper.trigger('contextmenu');
|
||||
@ -91,9 +101,10 @@ describe('ExplorerNode.vue', () => {
|
||||
parentId: node.item.id,
|
||||
});
|
||||
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 wrapper = mount(node);
|
||||
wrapper.trigger('contextmenu');
|
||||
@ -110,15 +121,16 @@ describe('ExplorerNode.vue', () => {
|
||||
parentId: node.item.id,
|
||||
});
|
||||
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();
|
||||
mount(node).trigger('contextmenu');
|
||||
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;
|
||||
mount(node).trigger('contextmenu');
|
||||
expect(specUtils.getContextMenuItem('New file').disabled).toBe(true);
|
||||
@ -139,9 +151,10 @@ describe('ExplorerNode.vue', () => {
|
||||
parentId: node.item.id,
|
||||
});
|
||||
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 wrapper = mount(node);
|
||||
wrapper.trigger('contextmenu');
|
||||
@ -158,27 +171,28 @@ describe('ExplorerNode.vue', () => {
|
||||
parentId: node.item.id,
|
||||
});
|
||||
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();
|
||||
mount(node).trigger('contextmenu');
|
||||
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;
|
||||
mount(node).trigger('contextmenu');
|
||||
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;
|
||||
mount(node).trigger('contextmenu');
|
||||
expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should rename files', async () => {
|
||||
it('should rename file', async () => {
|
||||
const node = await makeFileNode();
|
||||
const wrapper = mount(node);
|
||||
wrapper.trigger('contextmenu');
|
||||
@ -187,20 +201,10 @@ describe('ExplorerNode.vue', () => {
|
||||
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('renameFile');
|
||||
});
|
||||
|
||||
it('should rename folders', 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 () => {
|
||||
it('should cancel rename file on escape', async () => {
|
||||
const node = await makeFileNode();
|
||||
const wrapper = mount(node);
|
||||
wrapper.trigger('contextmenu');
|
||||
@ -211,6 +215,33 @@ describe('ExplorerNode.vue', () => {
|
||||
keyCode: 27,
|
||||
});
|
||||
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 () => {
|
||||
@ -225,23 +256,26 @@ describe('ExplorerNode.vue', () => {
|
||||
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 targetItem = await workspaceSvc.storeItem({ type: 'folder' });
|
||||
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 targetItem = await workspaceSvc.storeItem({ type: 'folder' });
|
||||
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 file = await workspaceSvc.createFile({ parentId: targetItem.id }, true);
|
||||
const sourceItem = await workspaceSvc.createFile({}, true);
|
||||
dragAndDrop(sourceItem, file);
|
||||
await specUtils.expectBadge('moveFile');
|
||||
});
|
||||
|
||||
it('should not move the trash folder', async () => {
|
||||
@ -256,14 +290,14 @@ describe('ExplorerNode.vue', () => {
|
||||
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 wrapper = mount(targetNode);
|
||||
wrapper.trigger('dragenter');
|
||||
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 targetNode = store.getters['explorer/nodeMap'][file.id];
|
||||
const wrapper = mount(targetNode);
|
||||
|
@ -3,15 +3,17 @@ import store from '../../../../src/store';
|
||||
import specUtils from '../specUtils';
|
||||
|
||||
describe('NavigationBar.vue', () => {
|
||||
it('should toggle the explorer', () => specUtils.checkToggler(
|
||||
it('should toggle the explorer', async () => specUtils.checkToggler(
|
||||
NavigationBar,
|
||||
wrapper => wrapper.find('.navigation-bar__button--explorer-toggler').trigger('click'),
|
||||
() => store.getters['data/layoutSettings'].showExplorer,
|
||||
'toggleExplorer',
|
||||
));
|
||||
|
||||
it('should toggle the side bar', () => specUtils.checkToggler(
|
||||
it('should toggle the side bar', async () => specUtils.checkToggler(
|
||||
NavigationBar,
|
||||
wrapper => wrapper.find('.navigation-bar__button--stackedit').trigger('click'),
|
||||
() => store.getters['data/layoutSettings'].showSideBar,
|
||||
'toggleSideBar',
|
||||
));
|
||||
});
|
||||
|
@ -25,12 +25,13 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
export default {
|
||||
checkToggler(Component, toggler, checker) {
|
||||
async checkToggler(Component, toggler, checker, featureId) {
|
||||
const wrapper = shallowMount(Component, { store });
|
||||
const valueBefore = checker();
|
||||
toggler(wrapper);
|
||||
const valueAfter = checker();
|
||||
expect(valueAfter).toEqual(!valueBefore);
|
||||
await this.expectBadge(featureId);
|
||||
},
|
||||
async resolveModal(type) {
|
||||
const config = store.getters['modal/config'];
|
||||
@ -47,5 +48,11 @@ export default {
|
||||
expect(item).toBeTruthy();
|
||||
store.state.contextMenu.resolve(item);
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user