Added tests

This commit is contained in:
Benoit Schweblin 2018-06-07 23:56:11 +00:00
parent 597c747b00
commit e05e7717eb
61 changed files with 5068 additions and 718 deletions

View File

@ -8,7 +8,7 @@
"env": {
"test": {
"presets": ["env", "stage-2"],
"plugins": [ "istanbul" ]
"plugins": ["transform-es2015-modules-commonjs", "dynamic-import-node"]
}
}
}

3
.gitignore vendored
View File

@ -3,8 +3,7 @@ node_modules/
dist/
.history
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.vscode
stackedit_v4
chrome-app/*.zip
/test/unit/coverage/

3415
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,9 @@
"build": "node build/build.js && npm run build-style",
"build-style": "webpack --config build/webpack.style.conf.js",
"lint": "eslint --ext .js,.vue src server",
"test": "npm run lint",
"unit": "jest --config test/unit/jest.conf.js --runInBand",
"unit-with-coverage": "jest --config test/unit/jest.conf.js --runInBand --coverage",
"test": "npm run lint && npm run unit",
"preversion": "npm run test",
"postversion": "git push origin master --tags && npm publish",
"patch": "npm version patch -m \"Tag v%s\"",
@ -22,7 +24,9 @@
"major": "npm version major -m \"Tag v%s\""
},
"dependencies": {
"@vue/test-utils": "^1.0.0-beta.16",
"aws-sdk": "^2.133.0",
"babel-runtime": "^6.26.0",
"bezier-easing": "^1.1.0",
"body-parser": "^1.18.2",
"clipboard": "^1.7.1",
@ -59,8 +63,11 @@
"autoprefixer": "^6.7.2",
"babel-core": "^6.26.3",
"babel-eslint": "^8.2.3",
"babel-jest": "^21.0.2",
"babel-loader": "^7.1.4",
"babel-plugin-transform-runtime": "^6.22.0",
"babel-plugin-dynamic-import-node": "^1.2.0",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "^6.23.0",
"babel-preset-env": "^1.3.2",
"babel-preset-stage-2": "^6.22.0",
@ -86,7 +93,11 @@
"gulp-concat": "^2.6.1",
"html-webpack-plugin": "^3.2.0",
"http-proxy-middleware": "^0.18.0",
"identity-obj-proxy": "^3.0.0",
"ignore-loader": "^0.1.2",
"jest": "^23.0.0",
"jest-raw-loader": "^1.0.1",
"jest-serializer-vue": "^0.3.0",
"node-sass": "^4.9.0",
"npm-bump": "^0.0.23",
"offline-plugin": "^5.0.3",
@ -103,6 +114,7 @@
"stylelint-processor-html": "^1.0.0",
"stylelint-webpack-plugin": "^0.10.4",
"url-loader": "^1.0.1",
"vue-jest": "^1.0.2",
"vue-loader": "^15.0.9",
"vue-style-loader": "^4.1.0",
"vue-template-compiler": "^2.5.16",
@ -114,12 +126,12 @@
"worker-loader": "^1.1.1"
},
"engines": {
"node": ">= 4.0.0",
"npm": ">= 3.0.0"
"node": ">= 8.0.0",
"npm": ">= 5.0.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8"
"not ie <= 10"
]
}

View File

@ -2,14 +2,13 @@
<div class="app" :class="classes">
<splash-screen v-if="!ready"></splash-screen>
<layout v-else></layout>
<modal v-if="showModal"></modal>
<modal></modal>
<notification></notification>
<context-menu></context-menu>
</div>
</template>
<script>
import Vue from 'vue';
import '../styles';
import '../styles/markdownHighlighting.scss';
import '../styles/app.scss';
@ -22,50 +21,7 @@ import syncSvc from '../services/syncSvc';
import networkSvc from '../services/networkSvc';
import sponsorSvc from '../services/sponsorSvc';
import tempFileSvc from '../services/tempFileSvc';
import timeSvc from '../services/timeSvc';
import store from '../store';
// Global directives
Vue.directive('focus', {
inserted(el) {
el.focus();
const { value } = el;
if (value && el.setSelectionRange) {
el.setSelectionRange(0, value.length);
}
},
});
const setVisible = (el, value) => {
el.style.display = value ? '' : 'none';
if (value) {
el.removeAttribute('aria-hidden');
} else {
el.setAttribute('aria-hidden', 'true');
}
};
Vue.directive('show', {
bind(el, { value }) {
setVisible(el, value);
},
update(el, { value, oldValue }) {
if (value !== oldValue) {
setVisible(el, value);
}
},
});
Vue.directive('title', {
bind(el, { value }) {
el.title = value;
el.setAttribute('aria-label', value);
},
});
// Global filters
Vue.filter('formatTime', time =>
// Access the minute counter for reactive refresh
timeSvc.format(time, store.state.minuteCounter));
import './common/globals';
const themeClasses = {
light: ['app--light'],
@ -88,9 +44,6 @@ export default {
const result = themeClasses[this.$store.getters['data/computedSettings'].colorTheme];
return Array.isArray(result) ? result : themeClasses.light;
},
showModal() {
return !!this.$store.getters['modal/config'];
},
},
async created() {
try {

View File

@ -1,24 +1,24 @@
<template>
<div class="button-bar">
<div class="button-bar__inner button-bar__inner--top">
<button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.showNavigationBar }" v-if="!light" @click="toggleNavigationBar()" v-title="'Toggle navigation bar'">
<button class="button-bar__button button-bar__button--navigation-bar-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showNavigationBar }" v-if="!light" @click="toggleNavigationBar()" v-title="'Toggle navigation bar'">
<icon-navigation-bar></icon-navigation-bar>
</button>
<button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.showSidePreview }" tour-step-anchor="editor" @click="toggleSidePreview()" v-title="'Toggle side preview'">
<button class="button-bar__button button-bar__button--side-preview-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showSidePreview }" tour-step-anchor="editor" @click="toggleSidePreview()" v-title="'Toggle side preview'">
<icon-side-preview></icon-side-preview>
</button>
<button class="button-bar__button button" @click="toggleEditor(false)" v-title="'Reader mode'">
<button class="button-bar__button button-bar__button--editor-toggler button" @click="toggleEditor(false)" v-title="'Reader mode'">
<icon-eye></icon-eye>
</button>
</div>
<div class="button-bar__inner button-bar__inner--bottom">
<button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.focusMode }" @click="toggleFocusMode()" v-title="'Toggle focus mode'">
<button class="button-bar__button button-bar__button--focus-mode-toggler button" :class="{ 'button-bar__button--on': layoutSettings.focusMode }" @click="toggleFocusMode()" v-title="'Toggle focus mode'">
<icon-target></icon-target>
</button>
<button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.scrollSync }" @click="toggleScrollSync()" v-title="'Toggle scroll sync'">
<button class="button-bar__button button-bar__button--scroll-sync-toggler button" :class="{ 'button-bar__button--on': layoutSettings.scrollSync }" @click="toggleScrollSync()" v-title="'Toggle scroll sync'">
<icon-scroll-sync></icon-scroll-sync>
</button>
<button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.showStatusBar }" @click="toggleStatusBar()" v-title="'Toggle status bar'">
<button class="button-bar__button button-bar__button--status-bar-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showStatusBar }" @click="toggleStatusBar()" v-title="'Toggle status bar'">
<icon-status-bar></icon-status-bar>
</button>
</div>

View File

@ -4,7 +4,7 @@
<div v-for="(item, idx) in items" :key="idx">
<div class="context-menu__separator" v-if="item.type === 'separator'"></div>
<div class="context-menu__item context-menu__item--disabled" v-else-if="item.disabled">{{item.name}}</div>
<a class="context-menu__item" href="javascript:void(0)" v-else @click.stop="close(item)">{{item.name}}</a>
<a class="context-menu__item" href="javascript:void(0)" v-else @click="close(item)">{{item.name}}</a>
</div>
</div>
</div>
@ -22,10 +22,8 @@ export default {
]),
},
methods: {
close(item) {
if (item) {
this.resolve(item);
}
close(item = null) {
this.resolve(item);
this.$store.dispatch('contextMenu/close');
},
},

View File

@ -2,20 +2,20 @@
<div class="explorer flex flex--column">
<div class="side-title flex flex--row flex--space-between">
<div class="flex flex--row">
<button class="side-title__button button" @click="newItem()" v-title="'New file'">
<button class="side-title__button side-title__button--new-file button" @click="newItem()" v-title="'New file'">
<icon-file-plus></icon-file-plus>
</button>
<button class="side-title__button button" @click="newItem(true)" v-title="'New folder'">
<button class="side-title__button side-title__button--new-folder button" @click="newItem(true)" v-title="'New folder'">
<icon-folder-plus></icon-folder-plus>
</button>
<button class="side-title__button button" @click="deleteItem()" v-title="'Delete'">
<button class="side-title__button side-title__button--delete button" @click="deleteItem()" v-title="'Delete'">
<icon-delete></icon-delete>
</button>
<button class="side-title__button button" @click="editItem()" v-title="'Rename'">
<button class="side-title__button side-title__button--rename button" @click="editItem()" v-title="'Rename'">
<icon-pen></icon-pen>
</button>
</div>
<button class="side-title__button button" @click="toggleExplorer(false)" v-title="'Close explorer'">
<button class="side-title__button side-title__button--close button" @click="toggleExplorer(false)" v-title="'Close explorer'">
<icon-close></icon-close>
</button>
</div>

View File

@ -181,7 +181,9 @@ export default {
perform: () => explorerSvc.deleteItem(),
}],
});
item.perform();
if (item) {
item.perform();
}
}
},
},

View File

@ -1,11 +1,11 @@
<template>
<div class="modal" @keydown.esc="onEscape" @keydown.tab="onTab">
<div class="modal" v-if="config" @keydown.esc="onEscape" @keydown.tab="onTab">
<component v-if="currentModalComponent" :is="currentModalComponent"></component>
<modal-inner v-else aria-label="Dialog">
<div class="modal__content" v-html="config.content"></div>
<div class="modal__content" v-html="simpleModal.contentHtml(config)"></div>
<div class="modal__button-bar">
<button class="button" v-if="config.rejectText" @click="config.reject()">{{config.rejectText}}</button>
<button class="button" v-if="config.resolveText" @click="config.resolve()">{{config.resolveText}}</button>
<button class="button" v-if="simpleModal.rejectText" @click="config.reject()">{{simpleModal.rejectText}}</button>
<button class="button" v-if="simpleModal.resolveText" @click="config.resolve()">{{simpleModal.resolveText}}</button>
</div>
</modal-inner>
</div>
@ -13,6 +13,7 @@
<script>
import { mapGetters } from 'vuex';
import simpleModals from '../data/simpleModals';
import editorSvc from '../services/editorSvc';
import ModalInner from './modals/common/ModalInner';
import FilePropertiesModal from './modals/FilePropertiesModal';
@ -112,6 +113,9 @@ export default {
}
return null;
},
simpleModal() {
return simpleModals[this.config.type] || {};
},
},
methods: {
onEscape() {
@ -153,14 +157,22 @@ export default {
},
},
mounted() {
window.addEventListener('focusin', this.onFocusInOut);
window.addEventListener('focusout', this.onFocusInOut);
const tabbables = getTabbables(this.$el);
tabbables[0].focus();
},
destroyed() {
window.removeEventListener('focusin', this.onFocusInOut);
window.removeEventListener('focusout', this.onFocusInOut);
this.$watch(
() => this.config,
() => {
if (this.$el) {
window.addEventListener('focusin', this.onFocusInOut);
window.addEventListener('focusout', this.onFocusInOut);
const tabbables = getTabbables(this.$el);
if (tabbables[0]) {
tabbables[0].focus();
}
} else {
window.removeEventListener('focusin', this.onFocusInOut);
window.removeEventListener('focusout', this.onFocusInOut);
}
},
);
},
};
</script>

View File

@ -3,7 +3,7 @@
<!-- Explorer -->
<div class="navigation-bar__inner navigation-bar__inner--left navigation-bar__inner--button">
<button class="navigation-bar__button button" v-if="light" @click="close()" v-title="'Close StackEdit'"><icon-close-circle></icon-close-circle></button>
<button class="navigation-bar__button button" v-else tour-step-anchor="explorer" @click="toggleExplorer()" v-title="'Toggle explorer'"><icon-folder></icon-folder></button>
<button class="navigation-bar__button navigation-bar__button--explorer-toggler button" v-else tour-step-anchor="explorer" @click="toggleExplorer()" v-title="'Toggle explorer'"><icon-folder></icon-folder></button>
</div>
<!-- Side bar -->
<div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--button">
@ -64,17 +64,16 @@ const mod = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'Meta' : 'Ctrl';
const getShortcut = (method) => {
let result = '';
Object.entries(store.getters['data/computedSettings'].shortcuts).some(([keys, shortcut]) => {
if (`${shortcut.method || shortcut}` !== method) {
return false;
if (`${shortcut.method || shortcut}` === method) {
result = keys.split('+').map(key => key.toLowerCase()).map((key) => {
if (key === 'mod') {
return mod;
}
// Capitalize
return key && `${key[0].toUpperCase()}${key.slice(1)}`;
}).join('+');
}
result = keys.split('+').map(key => key.toLowerCase()).map((key) => {
if (key === 'mod') {
return mod;
}
// Capitalize
return key && `${key[0].toUpperCase()}${key.slice(1)}`;
}).join('+');
return true;
return result;
});
return result && ` ${result}`;
};

View File

@ -1,17 +0,0 @@
<template>
<span class="provider-name">{{name}}</span>
</template>
<script>
export default {
props: ['providerId'],
computed: {
name() {
switch (this.userId) {
default:
return 'Google Drive';
}
},
},
};
</script>

View File

@ -0,0 +1,46 @@
import Vue from 'vue';
import timeSvc from '../../services/timeSvc';
import store from '../../store';
// Global directives
Vue.directive('focus', {
inserted(el) {
el.focus();
const { value } = el;
if (value && el.setSelectionRange) {
el.setSelectionRange(0, value.length);
}
},
});
const setVisible = (el, value) => {
el.style.display = value ? '' : 'none';
if (value) {
el.removeAttribute('aria-hidden');
} else {
el.setAttribute('aria-hidden', 'true');
}
};
Vue.directive('show', {
bind(el, { value }) {
setVisible(el, value);
},
update(el, { value, oldValue }) {
if (value !== oldValue) {
setVisible(el, value);
}
},
});
Vue.directive('title', {
bind(el, { value }) {
el.title = value;
el.setAttribute('aria-label', value);
},
});
// Global filters
Vue.filter('formatTime', time =>
// Access the minute counter for reactive refresh
timeSvc.format(time, store.state.minuteCounter));

View File

@ -49,7 +49,7 @@ export default {
]),
async removeComment() {
try {
await this.$store.dispatch('modal/commentDeletion');
await this.$store.dispatch('modal/open', 'commentDeletion');
this.$store.dispatch('discussion/cleanCurrentFile', { filterComment: this.comment });
} catch (e) {
// Cancel

View File

@ -95,7 +95,7 @@ export default {
},
async removeDiscussion() {
try {
await this.$store.dispatch('modal/discussionDeletion');
await this.$store.dispatch('modal/open', 'discussionDeletion');
this.$store.dispatch('discussion/cleanCurrentFile', {
filterDiscussion: this.currentDiscussion,
});

View File

@ -96,7 +96,7 @@ export default {
},
async reset() {
try {
await this.$store.dispatch('modal/reset');
await this.$store.dispatch('modal/open', 'reset');
window.location.href = '#reset=true';
window.location.reload();
} catch (e) {

View File

@ -68,7 +68,7 @@ export default modalTemplate({
FileSaver.saveAs(body, `${currentFile.name}.${selectedFormat}`);
} catch (err) {
if (err.status === 401) {
this.$store.dispatch('modal/sponsorOnly');
this.$store.dispatch('modal/open', 'sponsorOnly');
} else {
console.error(err); // eslint-disable-line no-console
this.$store.dispatch('notification/error', err);

View File

@ -65,7 +65,7 @@ export default modalTemplate({
FileSaver.saveAs(body, `${currentFile.name}.pdf`);
} catch (err) {
if (err.status === 401) {
this.$store.dispatch('modal/sponsorOnly');
this.$store.dispatch('modal/open', 'sponsorOnly');
} else {
console.error(err); // eslint-disable-line no-console
this.$store.dispatch('notification/error', err);

View File

@ -77,7 +77,7 @@ export default {
},
async remove(id) {
try {
await this.$store.dispatch('modal/removeWorkspace');
await this.$store.dispatch('modal/open', 'removeWorkspace');
localDbSvc.removeWorkspace(id);
} catch (e) {
// Cancel

View File

@ -33,7 +33,7 @@ export default {
try {
if (!this.$store.getters['workspace/sponsorToken']) {
// User has to sign in
await this.$store.dispatch('modal/signInForSponsorship');
await this.$store.dispatch('modal/open', 'signInForSponsorship');
await googleHelper.signin();
syncSvc.requestSync();
}

View File

@ -1,12 +1,12 @@
**Where is my data stored?**
If your workspace is not synced, your files are only stored inside your browser (using the [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)) and are not stored anywhere else.
If your workspace is not synced, your files are only stored inside your browser using the [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) and are not stored anywhere else.
We recommend syncing your workspace to make sure files won't be lost in case your browser data is cleared.
**Where is my data stored once I sync my workspace?**
If you sign in with Google, your main workspace will be stored in Google Drive (in your [app data folder](https://developers.google.com/drive/v3/web/appdata)).
If you sign in with Google, your main workspace will be stored in Google Drive, in your [app data folder](https://developers.google.com/drive/v3/web/appdata).
If you open a Google Drive workspace, the files in the workspace will be stored inside a Google Drive folder which you can share with other users.

View File

@ -1,4 +1,5 @@
export default [{}, {
export default [{
}, {
method: 'bold',
title: 'Bold',
icon: 'format-bold',
@ -14,7 +15,8 @@ export default [{}, {
method: 'strikethrough',
title: 'Strikethrough',
icon: 'format-strikethrough',
}, {}, {
}, {
}, {
method: 'ulist',
title: 'Unordered list',
icon: 'format-list-bulleted',
@ -26,7 +28,8 @@ export default [{}, {
method: 'clist',
title: 'Check list',
icon: 'format-list-checks',
}, {}, {
}, {
}, {
method: 'quote',
title: 'Blockquote',
icon: 'format-quote-close',

97
src/data/simpleModals.js Normal file
View File

@ -0,0 +1,97 @@
const simpleModal = (contentHtml, rejectText, resolveText) => ({
contentHtml: typeof contentHtml === 'function' ? contentHtml : () => contentHtml,
rejectText,
resolveText,
});
/* eslint sort-keys: "error" */
export default {
commentDeletion: simpleModal(
'<p>You are about to delete a comment. Are you sure?</p>',
'No',
'Yes, delete',
),
discussionDeletion: simpleModal(
'<p>You are about to delete a discussion. Are you sure?</p>',
'No',
'Yes, delete',
),
fileRestoration: simpleModal(
'<p>You are about to revert some changes. Are you sure?</p>',
'No',
'Yes, revert',
),
folderDeletion: simpleModal(
config => `<p>You are about to delete the folder <b>${config.item.name}</b>. Its files will be moved to Trash. Are you sure?</p>`,
'No',
'Yes, delete',
),
pathConflict: simpleModal(
config => `<p><b>${config.item.name}</b> already exists. Do you want to add a suffix?</p>`,
'No',
'Yes, add suffix',
),
paymentSuccess: simpleModal(
'<p>Thank you for your payment! Your sponsorship will be active in a minute.</p>',
'Ok',
),
providerRedirection: simpleModal(
config => `<p>You are about to navigate to the <b>${config.name}</b> authorization page.</p>`,
'Cancel',
'Ok, go on',
),
removeWorkspace: simpleModal(
'<p>You are about to remove a workspace locally. Are you sure?</p>',
'No',
'Yes, remove',
),
reset: simpleModal(
'<p>This will clean all your workspaces locally. Are you sure?</p>',
'No',
'Yes, clean',
),
signInForComment: simpleModal(
`<p>You have to sign in with Google to start commenting.</p>
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
'Cancel',
'Ok, sign in',
),
signInForSponsorship: simpleModal(
`<p>You have to sign in with Google to sponsor.</p>
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
'Cancel',
'Ok, sign in',
),
sponsorOnly: simpleModal(
'<p>This feature is restricted to sponsors as it relies on server resources.</p>',
'Ok, I understand',
),
stripName: simpleModal(
config => `<p><b>${config.item.name}</b> contains illegal characters. Do you want to strip them?</p>`,
'No',
'Yes, strip',
),
tempFileDeletion: simpleModal(
config => `<p>You are about to permanently delete the temporary file <b>${config.item.name}</b>. Are you sure?</p>`,
'No',
'Yes, delete',
),
tempFolderDeletion: simpleModal(
'<p>You are about to permanently delete all the temporary files. Are you sure?</p>',
'No',
'Yes, delete all',
),
trashDeletion: simpleModal(
'<p>Files in the trash are automatically deleted after 7 days of inactivity.</p>',
'Ok',
),
unauthorizedName: simpleModal(
config => `<p><b>${config.item.name}</b> is an unauthorized name.</p>`,
'Ok',
),
workspaceGoogleRedirection: simpleModal(
'<p>StackEdit needs full Google Drive access to open this workspace.</p>',
'Cancel',
'Ok, grant',
),
};

View File

@ -2,9 +2,9 @@ import Vue from 'vue';
import 'babel-polyfill';
import 'indexeddbshim/dist/indexeddbshim';
import * as OfflinePluginRuntime from 'offline-plugin/runtime';
import './extensions/';
import './extensions';
import './services/optional';
import './icons/';
import './icons';
import App from './components/App';
import store from './store';
import localDbSvc from './services/localDbSvc';

View File

@ -140,7 +140,8 @@ objectProperties.cl_extend = function (obj) {
function build(properties) {
return objectProperties.cl_reduce.call(properties, function (memo, value, key) {
memo[key] = {
value: value
value: value,
configurable: true
}
return memo
}, {})

View File

@ -20,9 +20,10 @@ export default {
if (selectedNode.isNil) {
return;
}
if (selectedNode.isTrash || selectedNode.item.parentId === 'trash') {
try {
await store.dispatch('modal/trashDeletion');
await store.dispatch('modal/open', 'trashDeletion');
} catch (e) {
// Cancel
}
@ -33,13 +34,19 @@ export default {
let moveToTrash = true;
try {
if (selectedNode.isTemp) {
await store.dispatch('modal/tempFolderDeletion', selectedNode.item);
await store.dispatch('modal/open', 'tempFolderDeletion');
moveToTrash = false;
} else if (selectedNode.item.parentId === 'temp') {
await store.dispatch('modal/tempFileDeletion', selectedNode.item);
await store.dispatch('modal/open', {
type: 'tempFileDeletion',
item: selectedNode.item,
});
moveToTrash = false;
} else if (selectedNode.isFolder) {
await store.dispatch('modal/folderDeletion', selectedNode.item);
await store.dispatch('modal/open', {
type: 'folderDeletion',
item: selectedNode.item,
});
}
} catch (e) {
return; // cancel

View File

@ -16,7 +16,7 @@ export default {
comments,
} = {}, background = false) {
const id = utils.uid();
const file = {
const item = {
id,
name: utils.sanitizeName(name),
parentId: parentId || null,
@ -34,23 +34,29 @@ export default {
// Show warning dialogs
if (!background) {
// If name is being stripped
if (file.name !== utils.defaultName && file.name !== name) {
await store.dispatch('modal/stripName', name);
if (item.name !== utils.defaultName && item.name !== name) {
await store.dispatch('modal/open', {
type: 'stripName',
item,
});
}
// Check if there is already a file with that path
if (workspaceUniquePaths) {
const parentPath = store.getters.itemPaths[file.parentId] || '';
const path = parentPath + file.name;
const parentPath = store.getters.itemPaths[item.parentId] || '';
const path = parentPath + item.name;
if (store.getters.pathItems[path]) {
await store.dispatch('modal/pathConflict', name);
await store.dispatch('modal/open', {
type: 'pathConflict',
item,
});
}
}
}
// Save file and content in the store
store.commit('content/setItem', content);
store.commit('file/setItem', file);
store.commit('file/setItem', item);
if (workspaceUniquePaths) {
this.makePathUnique(id);
}
@ -67,14 +73,20 @@ export default {
const sanitizedName = utils.sanitizeName(item.name);
if (item.type === 'folder' && forbiddenFolderNameMatcher.exec(sanitizedName)) {
await store.dispatch('modal/unauthorizedName', item.name);
await store.dispatch('modal/open', {
type: 'unauthorizedName',
item,
});
throw new Error('Unauthorized name.');
}
// Show warning dialogs
// If name has been stripped
if (sanitizedName !== utils.defaultName && sanitizedName !== item.name) {
await store.dispatch('modal/stripName', item.name);
await store.dispatch('modal/open', {
type: 'stripName',
item,
});
}
// Check if there is a path conflict
if (store.getters['workspace/hasUniquePaths']) {
@ -82,7 +94,10 @@ export default {
const path = parentPath + sanitizedName;
const pathItems = store.getters.pathItems[path] || [];
if (pathItems.some(itemWithSamePath => itemWithSamePath.id !== id)) {
await store.dispatch('modal/pathConflict', item.name);
await store.dispatch('modal/open', {
type: 'pathConflict',
item,
});
}
}

View File

@ -399,7 +399,7 @@ const localDbSvc = {
// Enable sponsorship
if (utils.queryParams.paymentSuccess) {
window.location.hash = ''; // PaymentSuccess param is always on its own
store.dispatch('modal/paymentSuccess')
store.dispatch('modal/open', 'paymentSuccess')
.catch(() => { /* Cancel */ });
const sponsorToken = store.getters['workspace/sponsorToken'];
// Force check sponsorship after a few seconds

View File

@ -215,13 +215,24 @@ export default {
const attempt = async () => {
try {
await new Promise((resolve, reject) => {
return await new Promise((resolve, reject) => {
if (offlineCheck) {
store.commit('updateLastOfflineCheck');
}
const xhr = new window.XMLHttpRequest();
xhr.withCredentials = config.withCredentials || false;
let timeoutId;
const timeoutId = setTimeout(() => {
xhr.abort();
if (offlineCheck) {
isConnectionDown = true;
store.commit('setOffline', true);
reject(new Error('You are offline.'));
} else {
reject(new Error('Network request timeout.'));
}
}, config.timeout);
xhr.onload = () => {
if (offlineCheck) {
@ -242,9 +253,9 @@ export default {
}
if (result.status >= 200 && result.status < 300) {
resolve(result);
return;
} else {
reject(result);
}
reject(result);
};
xhr.onerror = () => {
@ -258,21 +269,13 @@ export default {
}
};
timeoutId = setTimeout(() => {
xhr.abort();
if (offlineCheck) {
isConnectionDown = true;
store.commit('setOffline', true);
reject(new Error('You are offline.'));
} else {
reject(new Error('Network request timeout.'));
}
}, config.timeout);
const url = utils.addQueryParams(config.url, config.params);
xhr.open(config.method || 'GET', url);
Object.entries(config.headers).forEach(([key, value]) =>
value && xhr.setRequestHeader(key, `${value}`));
Object.entries(config.headers).forEach(([key, value]) => {
if (value) {
xhr.setRequestHeader(key, `${value}`);
}
});
if (config.blob) {
xhr.responseType = 'blob';
}
@ -286,7 +289,7 @@ export default {
// Exponential backoff
retryAfter *= 2;
});
attempt();
return attempt();
}
throw err;
}

View File

@ -91,7 +91,7 @@ export default new Provider({
syncLastSeq,
});
},
async saveSimpleItem(item, syncData) {
async saveWorkspaceItem(item, syncData) {
const syncToken = store.getters['workspace/syncToken'];
const { id, rev } = couchdbHelper.uploadDocument({
token: syncToken,
@ -108,78 +108,85 @@ export default new Provider({
rev,
};
},
removeItem(syncData) {
removeWorkspaceItem(syncData) {
const syncToken = store.getters['workspace/syncToken'];
return couchdbHelper.removeDocument(syncToken, syncData.id, syncData.rev);
},
downloadContent(token, syncLocation) {
return this.downloadData(`${syncLocation.fileId}/content`);
},
async downloadData(dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (!syncData) {
return Promise.resolve();
}
const syncToken = store.getters['workspace/syncToken'];
const body = await couchdbHelper.retrieveDocumentWithAttachments(syncToken, syncData.id);
let item;
if (body.item.type === 'content') {
item = Provider.parseContent(body.attachments.data, body.item.id);
} else {
item = utils.addItemHash(JSON.parse(body.attachments.data));
}
async downloadWorkspaceContent(token, syncData) {
const body = await couchdbHelper.retrieveDocumentWithAttachments(token, syncData.id);
const rev = body._rev; // eslint-disable-line no-underscore-dangle
if (item.hash !== syncData.hash || rev !== syncData.rev) {
store.dispatch('data/patchSyncData', {
[syncData.id]: {
...syncData,
hash: item.hash,
rev,
},
});
}
return item;
const item = Provider.parseContent(body.attachments.data, body.item.id);
return {
item,
syncData: {
...syncData,
hash: item.hash,
rev,
},
};
},
async uploadContent(token, content, syncLocation) {
await this.uploadData(content);
return syncLocation;
},
async uploadData(item) {
const syncData = store.getters['data/syncDataByItemId'][item.id];
if (!syncData || syncData.hash !== item.hash) {
let data;
let dataType;
if (item.type === 'content') {
data = Provider.serializeContent(item);
dataType = 'text/plain';
} else {
data = JSON.stringify(item);
dataType = 'application/json';
}
const syncToken = store.getters['workspace/syncToken'];
const res = await couchdbHelper.uploadDocument({
token: syncToken,
item: {
id: item.id,
type: item.type,
hash: item.hash,
},
data,
dataType,
documentId: syncData && syncData.id,
rev: syncData && syncData.rev,
});
store.dispatch('data/patchSyncData', {
[res.id]: {
// Build sync data
id: res.id,
itemId: item.id,
type: item.type,
hash: item.hash,
rev: res.rev,
},
});
async downloadWorkspaceData(token, dataId, syncData) {
if (!syncData) {
return {};
}
const body = await couchdbHelper.retrieveDocumentWithAttachments(token, syncData.id);
const item = utils.addItemHash(JSON.parse(body.attachments.data));
const rev = body._rev; // eslint-disable-line no-underscore-dangle
return {
item,
syncData: {
...syncData,
hash: item.hash,
rev,
},
};
},
async uploadWorkspaceContent(token, item, syncData) {
const res = await couchdbHelper.uploadDocument({
token,
item: {
id: item.id,
type: item.type,
hash: item.hash,
},
data: Provider.serializeContent(item),
dataType: 'text/plain',
documentId: syncData && syncData.id,
rev: syncData && syncData.rev,
});
// Return new sync data
return {
id: res.id,
itemId: item.id,
type: item.type,
hash: item.hash,
rev: res.rev,
};
},
async uploadWorkspaceData(token, item, syncData) {
const res = await couchdbHelper.uploadDocument({
token,
item: {
id: item.id,
type: item.type,
hash: item.hash,
},
data: JSON.stringify(item),
dataType: 'application/json',
documentId: syncData && syncData.id,
rev: syncData && syncData.rev,
});
// Return new sync data
return {
id: res.id,
itemId: item.id,
type: item.type,
hash: item.hash,
rev: res.rev,
};
},
async listRevisions(token, fileId) {
const syncData = Provider.getContentSyncData(fileId);

View File

@ -28,6 +28,7 @@ const endsWith = (str, suffix) => str.slice(-suffix.length) === suffix;
export default new Provider({
id: 'githubWorkspace',
isGit: true,
getToken() {
return store.getters['workspace/syncToken'];
},
@ -110,29 +111,31 @@ export default new Provider({
const path = blobEntry.path.slice(workspacePath.length);
// Collect blob sha
treeShaMap[path] = blobEntry.sha;
// Collect parents path
let parentPath = '';
path.split('/').slice(0, -1).forEach((folderName) => {
const folderPath = `${parentPath}${folderName}/`;
treeFolderMap[folderPath] = parentPath;
parentPath = folderPath;
});
// Collect file path
if (path.indexOf('.stackedit-data/') === 0) {
treeDataMap[path] = true;
} else if (endsWith(path, '.md')) {
treeFileMap[path] = parentPath;
} else if (endsWith(path, '.sync')) {
treeSyncLocationMap[path] = true;
} else if (endsWith(path, '.publish')) {
treePublishLocationMap[path] = true;
} else {
// Collect parents path
let parentPath = '';
path.split('/').slice(0, -1).forEach((folderName) => {
const folderPath = `${parentPath}${folderName}/`;
treeFolderMap[folderPath] = parentPath;
parentPath = folderPath;
});
// Collect file path
if (endsWith(path, '.md')) {
treeFileMap[path] = parentPath;
} else if (endsWith(path, '.sync')) {
treeSyncLocationMap[path] = true;
} else if (endsWith(path, '.publish')) {
treePublishLocationMap[path] = true;
}
}
});
// Collect changes
const changes = [];
const pathIds = {};
const syncDataToIgnore = Object.create(null);
const syncDataToKeep = Object.create(null);
const getId = (path) => {
const syncData = syncDataByPath[path];
const id = syncData ? syncData.itemId : utils.uid();
@ -185,7 +188,7 @@ export default new Provider({
// Content creations/updates
const contentSyncData = syncDataByItemId[`${id}/content`];
if (contentSyncData) {
syncDataToIgnore[contentSyncData.id] = true;
syncDataToKeep[contentSyncData.id] = true;
}
if (!contentSyncData || contentSyncData.sha !== treeShaMap[path]) {
// Use `/` as a prefix to get a unique syncData id
@ -211,11 +214,12 @@ export default new Provider({
// Data creations/updates
Object.keys(treeDataMap).forEach((path) => {
try {
const [, id] = path.match(/^\.stackedit-data\/([\s\S]+)\.json$/);
// Only template data are stored
const [, id] = path.match(/^\.stackedit-data\/(templates)\.json$/) || [];
pathIds[path] = id;
const syncData = syncDataByItemId[id];
if (syncData) {
syncDataToIgnore[syncData.id] = true;
syncDataToKeep[syncData.id] = true;
}
if (!syncData || syncData.sha !== treeShaMap[path]) {
changes.push({
@ -281,40 +285,28 @@ export default new Provider({
// Deletions
Object.keys(syncDataByPath).forEach((path) => {
if (!pathIds[path] && !syncDataToIgnore[path]) {
if (!pathIds[path] && !syncDataToKeep[path]) {
changes.push({ syncDataId: path });
}
});
return changes;
},
async saveSimpleItem(item) {
const path = store.getters.itemPaths[item.fileId || item.id];
const syncToken = store.getters['workspace/syncToken'];
async saveWorkspaceItem(item) {
const syncData = {
id: store.getters.itemGitPaths[item.id],
itemId: item.id,
type: item.type,
hash: item.hash,
};
if (item.type === 'file') {
syncData.id = `${path}.md`;
return syncData;
}
if (item.type === 'folder') {
syncData.id = path;
// Files and folders are not in git, only contents
if (item.type === 'file' || item.type === 'folder') {
return syncData;
}
// locations are stored as paths, so we upload an empty file
const data = utils.encodeBase64(utils.serializeObject({
...item,
id: undefined,
type: undefined,
fileId: undefined,
}), true);
const extension = item.type === 'syncLocation' ? 'sync' : 'publish';
syncData.id = `${path}.${data}.${extension}`;
const syncToken = store.getters['workspace/syncToken'];
await githubHelper.uploadFile({
...getWorkspaceWithOwner(),
token: syncToken,
@ -324,9 +316,8 @@ export default new Provider({
});
return syncData;
},
async removeItem(syncData) {
// Ignore content deletion
if (syncData.type !== 'content') {
async removeWorkspaceItem(syncData) {
if (treeShaMap[syncData.id]) {
const syncToken = store.getters['workspace/syncToken'];
await githubHelper.removeFile({
...getWorkspaceWithOwner(),
@ -336,102 +327,91 @@ export default new Provider({
});
}
},
async downloadContent(token, syncLocation) {
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId];
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
if (!syncData || !contentSyncData) {
return null;
async downloadWorkspaceContent(token, contentSyncData) {
const [fileId] = contentSyncData.itemId.split('/');
const path = store.getters.itemGitPaths[fileId];
const syncData = store.getters['data/syncData'][path];
if (!syncData) {
return {};
}
const { sha, content } = await githubHelper.downloadFile({
...getWorkspaceWithOwner(),
token,
path: getAbsolutePath(syncData),
});
const item = Provider.parseContent(content, `${syncLocation.fileId}/content`);
if (item.hash !== contentSyncData.hash) {
store.dispatch('data/patchSyncData', {
[contentSyncData.id]: {
...contentSyncData,
hash: item.hash,
sha,
},
});
}
return item;
treeShaMap[path] = sha;
const item = Provider.parseContent(content, `${fileId}/content`);
return {
item,
syncData: {
...contentSyncData,
hash: item.hash,
sha,
},
};
},
async downloadData(dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
async downloadWorkspaceData(token, dataId, syncData) {
if (!syncData) {
return null;
return {};
}
const syncToken = store.getters['workspace/syncToken'];
const { sha, content } = await githubHelper.downloadFile({
...getWorkspaceWithOwner(),
token: syncToken,
token,
path: getAbsolutePath(syncData),
});
treeShaMap[syncData.id] = sha;
const item = JSON.parse(content);
if (item.hash !== syncData.hash) {
store.dispatch('data/patchSyncData', {
[syncData.id]: {
...syncData,
hash: item.hash,
sha,
},
});
}
return item;
},
async uploadContent(token, content, syncLocation) {
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
if (!contentSyncData || contentSyncData.hash !== content.hash) {
const path = `${store.getters.itemPaths[syncLocation.fileId]}.md`;
const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`;
const id = `/${path}`;
const res = await githubHelper.uploadFile({
...getWorkspaceWithOwner(),
token,
path: absolutePath,
content: Provider.serializeContent(content),
sha: treeShaMap[id],
});
store.dispatch('data/patchSyncData', {
[id]: {
// Build sync data
id,
itemId: content.id,
type: content.type,
hash: content.hash,
sha: res.content.sha,
},
});
}
return syncLocation;
},
async uploadData(item) {
const oldSyncData = store.getters['data/syncDataByItemId'][item.id];
if (!oldSyncData || oldSyncData.hash !== item.hash) {
const syncData = {
id: `.stackedit-data/${item.id}.json`,
itemId: item.id,
type: item.type,
return {
item,
syncData: {
...syncData,
hash: item.hash,
};
const syncToken = store.getters['workspace/syncToken'];
const res = await githubHelper.uploadFile({
...getWorkspaceWithOwner(),
token: syncToken,
path: getAbsolutePath(syncData),
content: JSON.stringify(item),
sha: oldSyncData && oldSyncData.sha,
});
store.dispatch('data/patchSyncData', {
[syncData.id]: {
...syncData,
sha: res.content.sha,
},
});
}
sha,
},
};
},
async uploadWorkspaceContent(token, item) {
const [fileId] = item.id.split('/');
const path = store.getters.itemGitPaths[fileId];
const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`;
const res = await githubHelper.uploadFile({
...getWorkspaceWithOwner(),
token,
path: absolutePath,
content: Provider.serializeContent(item),
sha: treeShaMap[path],
});
// Return new sync data
return {
id: store.getters.itemGitPaths[item.id],
itemId: item.id,
type: item.type,
hash: item.hash,
sha: res.content.sha,
};
},
async uploadWorkspaceData(token, item) {
const path = store.getters.itemGitPaths[item.id];
const syncData = {
id: path,
itemId: item.id,
type: item.type,
hash: item.hash,
};
const res = await githubHelper.uploadFile({
...getWorkspaceWithOwner(),
token,
path: getAbsolutePath(syncData),
content: JSON.stringify(item),
sha: treeShaMap[path],
});
return {
...syncData,
sha: res.content.sha,
};
},
onSyncEnd() {
// Clean up

View File

@ -48,7 +48,7 @@ export default new Provider({
syncStartPageToken,
});
},
async saveSimpleItem(item, syncData, ifNotTooLate) {
async saveWorkspaceItem(item, syncData, ifNotTooLate) {
const syncToken = store.getters['workspace/syncToken'];
const file = await googleHelper.uploadAppDataFile({
token: syncToken,
@ -64,60 +64,77 @@ export default new Provider({
hash: item.hash,
};
},
removeItem(syncData, ifNotTooLate) {
removeWorkspaceItem(syncData, ifNotTooLate) {
const syncToken = store.getters['workspace/syncToken'];
return googleHelper.removeAppDataFile(syncToken, syncData.id, ifNotTooLate);
},
downloadContent(token, syncLocation) {
return this.downloadData(`${syncLocation.fileId}/content`);
},
async downloadData(dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (!syncData) {
return null;
}
const syncToken = store.getters['workspace/syncToken'];
const data = await googleHelper.downloadAppDataFile(syncToken, syncData.id);
async downloadWorkspaceContent(token, syncData) {
const data = await googleHelper.downloadAppDataFile(token, syncData.id);
const item = utils.addItemHash(JSON.parse(data));
if (item.hash !== syncData.hash) {
store.dispatch('data/patchSyncData', {
[syncData.id]: {
...syncData,
hash: item.hash,
},
});
}
return item;
return {
item,
syncData: {
...syncData,
hash: item.hash,
},
};
},
async uploadContent(token, content, syncLocation, ifNotTooLate) {
await this.uploadData(content, ifNotTooLate);
return syncLocation;
},
async uploadData(item, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][item.id];
if (!syncData || syncData.hash !== item.hash) {
const syncToken = store.getters['workspace/syncToken'];
const file = await googleHelper.uploadAppDataFile({
token: syncToken,
name: JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
media: JSON.stringify(item),
fileId: syncData && syncData.id,
ifNotTooLate,
});
store.dispatch('data/patchSyncData', {
[file.id]: {
// Build sync data
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
},
});
async downloadWorkspaceData(token, dataId, syncData) {
if (!syncData) {
return {};
}
const data = await googleHelper.downloadAppDataFile(token, syncData.id);
const item = utils.addItemHash(JSON.parse(data));
return {
item,
syncData: {
...syncData,
hash: item.hash,
},
};
},
async uploadWorkspaceContent(token, item, syncData, ifNotTooLate) {
const file = await googleHelper.uploadAppDataFile({
token,
name: JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
media: JSON.stringify(item),
fileId: syncData && syncData.id,
ifNotTooLate,
});
// Return new sync data
return {
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
};
},
async uploadWorkspaceData(token, item, syncData, ifNotTooLate) {
const file = await googleHelper.uploadAppDataFile({
token,
name: JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
media: JSON.stringify(item),
fileId: syncData && syncData.id,
ifNotTooLate,
});
// Return new sync data
return {
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
};
},
async listRevisions(token, fileId) {
const syncData = Provider.getContentSyncData(fileId);

View File

@ -89,7 +89,7 @@ export default new Provider({
let token = store.getters['data/googleTokens'][sub];
// If no token has been found, popup an authorize window and get one
if (!token || !token.isDrive || !token.driveFullAccess) {
await store.dispatch('modal/workspaceGoogleRedirection');
await store.dispatch('modal/open', 'workspaceGoogleRedirection');
token = await googleHelper.addDriveAccount(true, utils.queryParams.sub);
}
@ -312,7 +312,7 @@ export default new Provider({
syncStartPageToken,
});
},
async saveSimpleItem(item, syncData, ifNotTooLate) {
async saveWorkspaceItem(item, syncData, ifNotTooLate) {
const workspace = store.getters['workspace/currentWorkspace'];
const syncToken = store.getters['workspace/syncToken'];
let file;
@ -363,133 +363,88 @@ export default new Provider({
hash: item.hash,
};
},
async removeItem(syncData, ifNotTooLate) {
async removeWorkspaceItem(syncData, ifNotTooLate) {
// Ignore content deletion
if (syncData.type !== 'content') {
const syncToken = store.getters['workspace/syncToken'];
await googleHelper.removeFile(syncToken, syncData.id, ifNotTooLate);
}
},
async downloadContent(token, syncLocation) {
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId];
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
if (!syncData || !contentSyncData) {
return null;
async downloadWorkspaceContent(token, contentSyncData) {
const [fileId] = contentSyncData.itemId.split('/');
const syncData = store.getters['data/syncDataByItemId'][fileId];
if (!syncData) {
return {};
}
const content = await googleHelper.downloadFile(token, syncData.id);
const item = Provider.parseContent(content, `${syncLocation.fileId}/content`);
if (item.hash !== contentSyncData.hash) {
store.dispatch('data/patchSyncData', {
[contentSyncData.id]: {
...contentSyncData,
hash: item.hash,
},
});
}
const item = Provider.parseContent(content, contentSyncData.itemId);
// Open the file requested by action if it wasn't synced yet
if (fileIdToOpen && fileIdToOpen === syncData.id) {
fileIdToOpen = null;
// Open the file once downloaded content has been stored
setTimeout(() => {
store.commit('file/setCurrentId', syncData.itemId);
store.commit('file/setCurrentId', fileId);
}, 10);
}
return item;
return {
item,
syncData: {
...contentSyncData,
hash: item.hash,
},
};
},
async downloadData(dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
async downloadWorkspaceData(token, dataId, syncData) {
if (!syncData) {
return null;
return {};
}
const syncToken = store.getters['workspace/syncToken'];
const content = await googleHelper.downloadFile(syncToken, syncData.id);
const content = await googleHelper.downloadFile(token, syncData.id);
const item = JSON.parse(content);
if (item.hash !== syncData.hash) {
store.dispatch('data/patchSyncData', {
[syncData.id]: {
...syncData,
hash: item.hash,
},
});
}
return item;
return {
item,
syncData: {
...syncData,
hash: item.hash,
},
};
},
async uploadContent(token, content, syncLocation, ifNotTooLate) {
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
if (!contentSyncData || contentSyncData.hash !== content.hash) {
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId];
let file;
if (syncData) {
// Only update file media
file = await googleHelper.uploadFile({
token,
media: Provider.serializeContent(content),
fileId: syncData.id,
ifNotTooLate,
});
} else {
// Create file with media
const workspace = store.getters['workspace/currentWorkspace'];
// Use deepCopy to freeze objects
const item = utils.deepCopy(store.state.file.itemMap[syncLocation.fileId]);
const parentSyncData = store.getters['data/syncDataByItemId'][item.parentId];
file = await googleHelper.uploadFile({
token,
name: item.name,
parents: [parentSyncData ? parentSyncData.id : workspace.folderId],
appProperties: {
id: item.id,
folderId: workspace.folderId,
},
media: Provider.serializeContent(content),
ifNotTooLate,
});
store.dispatch('data/patchSyncData', {
[file.id]: {
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
},
});
}
store.dispatch('data/patchSyncData', {
[`${file.id}/content`]: {
// Build sync data
id: `${file.id}/content`,
itemId: content.id,
type: content.type,
hash: content.hash,
},
});
}
return syncLocation;
},
async uploadData(item, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][item.id];
if (!syncData || syncData.hash !== item.hash) {
const workspace = store.getters['workspace/currentWorkspace'];
const syncToken = store.getters['workspace/syncToken'];
const file = await googleHelper.uploadFile({
token: syncToken,
name: JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
parents: [workspace.dataFolderId],
appProperties: {
folderId: workspace.folderId,
},
media: JSON.stringify(item),
fileId: syncData && syncData.id,
oldParents: syncData && syncData.parentIds,
async uploadWorkspaceContent(token, content, contentSyncData, ifNotTooLate) {
const [fileId] = content.id.split('/');
const syncData = store.getters['data/syncDataByItemId'][fileId];
let file;
if (syncData) {
// Only update file media
file = await googleHelper.uploadFile({
token,
media: Provider.serializeContent(content),
fileId: syncData.id,
ifNotTooLate,
});
} else {
// Create file with media
const workspace = store.getters['workspace/currentWorkspace'];
// Use deepCopy to freeze objects
const item = utils.deepCopy(store.state.file.itemMap[fileId]);
const parentSyncData = store.getters['data/syncDataByItemId'][item.parentId];
file = await googleHelper.uploadFile({
token,
name: item.name,
parents: [parentSyncData ? parentSyncData.id : workspace.folderId],
appProperties: {
id: item.id,
folderId: workspace.folderId,
},
media: Provider.serializeContent(content),
ifNotTooLate,
});
// Create file syncData
store.dispatch('data/patchSyncData', {
[file.id]: {
// Build sync data
id: file.id,
itemId: item.id,
type: item.type,
@ -497,6 +452,41 @@ export default new Provider({
},
});
}
// Return new sync data
return {
id: `${file.id}/content`,
itemId: content.id,
type: content.type,
hash: content.hash,
};
},
async uploadWorkspaceData(token, item, syncData, ifNotTooLate) {
const workspace = store.getters['workspace/currentWorkspace'];
const file = await googleHelper.uploadFile({
token,
name: JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
parents: [workspace.dataFolderId],
appProperties: {
folderId: workspace.folderId,
},
media: JSON.stringify(item),
fileId: syncData && syncData.id,
oldParents: syncData && syncData.parentIds,
ifNotTooLate,
});
// Return new sync data
return {
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
};
},
async listRevisions(token, fileId) {
const syncData = Provider.getContentSyncData(fileId);

View File

@ -113,12 +113,12 @@ export default {
repo,
branch,
}) {
const { commit } = (await repoRequest(token, owner, repo, {
const { commit } = await repoRequest(token, owner, repo, {
url: `commits/${encodeURIComponent(branch)}`,
})).body;
const { tree, truncated } = (await repoRequest(token, owner, repo, {
});
const { tree, truncated } = await repoRequest(token, owner, repo, {
url: `git/trees/${encodeURIComponent(commit.tree.sha)}?recursive=1`,
})).body;
});
if (truncated) {
throw new Error('Git tree too big. Please remove some files in the repository.');
}
@ -198,13 +198,13 @@ export default {
branch,
path,
}) {
const body = await repoRequest(token, owner, repo, {
const { sha, content } = await repoRequest(token, owner, repo, {
url: `contents/${encodeURIComponent(path)}`,
params: { ref: branch },
});
return {
sha: body.sha,
content: utils.decodeBase64(body.content),
sha,
content: utils.decodeBase64(content),
};
},

View File

@ -190,7 +190,10 @@ export default {
if (store.state.offline) {
throw err;
}
await store.dispatch('modal/providerRedirection', { providerName: 'Google' });
await store.dispatch('modal/open', {
type: 'providerRedirection',
name: 'Google',
});
return this.startOauth2(mergedScopes, sub);
}
},
@ -231,7 +234,7 @@ export default {
mediaType = null,
fileId = null,
oldParents = null,
ifNotTooLate = cb => res => cb(res),
ifNotTooLate = cb => cb(),
}) {
// Refreshing a token can take a while if an oauth window pops up, make sure it's not too late
return ifNotTooLate(() => {
@ -374,7 +377,7 @@ export default {
/**
* https://developers.google.com/drive/v3/reference/files/delete
*/
async $removeFile(refreshedToken, id, ifNotTooLate = cb => res => cb(res)) {
async $removeFile(refreshedToken, id, ifNotTooLate = cb => cb()) {
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late
return ifNotTooLate(() => this.$request(refreshedToken, {
method: 'DELETE',
@ -388,7 +391,7 @@ export default {
const refreshedToken = await this.refreshToken(token, getDriveScopes(token));
return this.$removeFile(refreshedToken, id, ifNotTooLate);
},
async removeAppDataFile(token, id, ifNotTooLate = cb => res => cb(res)) {
async removeAppDataFile(token, id, ifNotTooLate = cb => cb()) {
const refreshedToken = await this.refreshToken(token, driveAppDataScopes);
return this.$removeFile(refreshedToken, id, ifNotTooLate);
},
@ -599,7 +602,7 @@ export default {
}
const refreshedToken = await this.refreshToken(token, scopes);
const { google } = window;
return Promise((resolve) => {
return new Promise((resolve) => {
let picker;
const pickerBuilder = new google.picker.PickerBuilder()
.setOAuthToken(refreshedToken.accessToken)

View File

@ -57,7 +57,10 @@ export default {
}
// Existing token is going to expire.
// Try to get a new token in background
await store.dispatch('modal/providerRedirection', { providerName: 'WordPress' });
await store.dispatch('modal/open', {
type: 'providerRedirection',
name: 'WordPress',
});
return this.startOauth2(sub);
},
addAccount(fullAccess = false) {

View File

@ -103,24 +103,22 @@ const requestPublish = () => {
return;
}
store.dispatch('queue/enqueuePublishRequest', () => new Promise((resolve, reject) => {
store.dispatch('queue/enqueuePublishRequest', async () => {
let intervalId;
const attempt = () => {
const attempt = async () => {
// Only start publishing when these conditions are met
if (networkSvc.isUserActive()) {
clearInterval(intervalId);
if (!hasCurrentFilePublishLocations()) {
// Cancel sync
reject(new Error('Publish not possible.'));
return;
throw new Error('Publish not possible.');
}
publishFile(store.getters['file/current'].id)
.then(resolve, reject);
await publishFile(store.getters['file/current'].id);
}
};
intervalId = utils.setInterval(() => attempt(), 1000);
attempt();
}));
return attempt();
});
};
const createPublishLocation = (publishLocation) => {

View File

@ -206,24 +206,30 @@ const createSyncLocation = (syncLocation) => {
);
};
// Prevent from sending new data too long after old data has been fetched
/**
* Prevent from sending new data too long after old data has been fetched.
*/
const tooLateChecker = (timeout) => {
const tooLateAfter = Date.now() + timeout;
return cb => (res) => {
return (cb) => {
if (tooLateAfter < Date.now()) {
throw new Error('TOO_LATE');
}
return cb(res);
return cb();
};
};
/**
* Return true if file is in the temp folder or it's a welcome file.
*/
const isTempFile = (fileId) => {
if (store.getters['data/syncDataByItemId'][`${fileId}/content`]) {
const contentId = `${fileId}/content`;
if (store.getters['data/syncDataByItemId'][contentId]) {
// If file has already been synced, it's not a temp file
return false;
}
const file = store.state.file.itemMap[fileId];
const content = store.state.content.itemMap[`${fileId}/content`];
const content = store.state.content.itemMap[contentId];
if (!file || !content) {
return false;
}
@ -254,15 +260,17 @@ class SyncContext {
* Sync one file with all its locations.
*/
const syncFile = async (fileId, syncContext = new SyncContext()) => {
syncContext.attempted[`${fileId}/content`] = true;
const contentId = `${fileId}/content`;
syncContext.attempted[contentId] = true;
await localDbSvc.loadSyncedContent(fileId);
try {
await localDbSvc.loadItem(`${fileId}/content`);
await localDbSvc.loadItem(contentId);
} catch (e) {
// Item may not exist if content has not been downloaded yet
}
const getContent = () => store.state.content.itemMap[`${fileId}/content`];
const getContent = () => store.state.content.itemMap[contentId];
const getSyncedContent = () => upgradeSyncedContent(store.state.syncedContent.itemMap[`${fileId}/syncedContent`]);
const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId];
@ -288,28 +296,88 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
return;
}
const downloadContent = async () => {
// On simple provider, call simply downloadContent
if (syncLocation.id !== 'main') {
return provider.downloadContent(token, syncLocation);
}
// On workspace provider, call downloadWorkspaceContent
const oldSyncData = provider.isGit
? store.getters['data/syncData'][store.getters.itemGitPaths[contentId]]
: store.getters['data/syncDataByItemId'][contentId];
if (!oldSyncData) {
return null;
}
const { item, syncData } = await provider.downloadWorkspaceContent(token, oldSyncData);
if (!item) {
return null;
}
// Update sync data if changed
if (syncData
&& utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)
) {
store.dispatch('data/patchSyncData', {
[syncData.id]: syncData,
});
}
return item;
};
const uploadContent = async (item, ifNotTooLate) => {
// On simple provider, call simply uploadContent
if (syncLocation.id !== 'main') {
return provider.uploadContent(token, item, syncLocation, ifNotTooLate);
}
// On workspace provider, call uploadWorkspaceContent
const oldSyncData = provider.isGit
? store.getters['data/syncData'][store.getters.itemGitPaths[contentId]]
: store.getters['data/syncDataByItemId'][contentId];
if (oldSyncData && oldSyncData.hash === item.hash) {
return syncLocation;
}
const syncData = await provider.uploadWorkspaceContent(
token,
item,
oldSyncData,
ifNotTooLate,
);
// Update sync data if changed
if (syncData
&& utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)
) {
store.dispatch('data/patchSyncData', {
[syncData.id]: syncData,
});
}
// Return syncLocation
return syncLocation;
};
const doSyncLocation = async () => {
const serverContent = await provider.downloadContent(token, syncLocation);
const serverContent = await downloadContent(token, syncLocation);
const syncedContent = getSyncedContent();
const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
let mergedContent = (() => {
const clientContent = utils.deepCopy(getContent());
if (!clientContent) {
return utils.deepCopy(serverContent || null);
}
if (!serverContent) {
// Sync location has not been created yet
return clientContent;
}
if (serverContent.hash === clientContent.hash) {
// Server and client contents are synced
return clientContent;
}
if (syncedContent.historyData[serverContent.hash]) {
// Server content has not changed or has already been merged
return clientContent;
}
// Perform a merge with last merged content if any, or a simple fusion otherwise
// Merge content
let mergedContent;
const clientContent = utils.deepCopy(getContent());
if (!clientContent) {
mergedContent = utils.deepCopy(serverContent || null);
} else if (!serverContent // If sync location has not been created yet
// Or server and client contents are synced
|| serverContent.hash === clientContent.hash
// Or server content has not changed or has already been merged
|| syncedContent.historyData[serverContent.hash]
) {
mergedContent = clientContent;
} else {
// Perform a merge with last merged content if any, or perform a simple fusion otherwise
let lastMergedContent = utils.someResult(
serverContent.history,
hash => syncedContent.historyData[hash],
@ -317,16 +385,15 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
if (!lastMergedContent && syncHistoryItem) {
lastMergedContent = syncedContent.historyData[syncHistoryItem[LAST_MERGED]];
}
return diffUtils.mergeContent(serverContent, clientContent, lastMergedContent);
})();
mergedContent = diffUtils.mergeContent(serverContent, clientContent, lastMergedContent);
}
if (!mergedContent) {
return;
}
// Update or set content in store
store.commit('content/setItem', {
id: `${fileId}/content`,
id: contentId,
text: utils.sanitizeText(mergedContent.text),
properties: utils.sanitizeText(mergedContent.properties),
discussions: mergedContent.discussions,
@ -356,8 +423,7 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
}
}
// Store last sent if it's in the server history,
// and merged content which will be sent if different
// Update synced content
const newSyncedContent = utils.deepCopy(syncedContent);
const newSyncHistoryItem = newSyncedContent.syncHistory[syncLocation.id] || [];
newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem;
@ -384,10 +450,14 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
}
// Upload merged content
const syncLocationToStore = await provider.uploadContent(token, {
const item = {
...mergedContent,
history: mergedContentHistory.slice(0, maxContentHistory),
}, syncLocation, tooLateChecker(restartContentSyncAfter));
};
const syncLocationToStore = await uploadContent(
item,
tooLateChecker(restartContentSyncAfter),
);
// Replace sync location if modified
if (utils.serializeObject(syncLocation) !==
@ -437,14 +507,30 @@ const syncDataItem = async (dataId) => {
const getItem = () => store.state.data.itemMap[dataId]
|| store.state.data.lsItemMap[dataId];
const item = getItem();
const syncData = store.getters['data/syncDataByItemId'][dataId];
const oldItem = getItem();
const oldSyncData = store.getters['data/syncDataByItemId'][dataId];
// Sync if item hash and syncData hash are out of sync
if (syncData && item && item.hash === syncData.hash) {
if (oldSyncData && oldItem && oldItem.hash === oldSyncData.hash) {
return;
}
const serverItem = await workspaceProvider.downloadData(dataId);
const token = workspaceProvider.getToken();
const { item, syncData } = await workspaceProvider.downloadWorkspaceData(
token,
dataId,
oldSyncData,
);
// Update sync data if changed
if (syncData
&& utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)
) {
store.dispatch('data/patchSyncData', {
[syncData.id]: syncData,
});
}
const serverItem = item;
const dataSyncData = store.getters['data/dataSyncData'][dataId];
let mergedItem = (() => {
const clientItem = utils.deepCopy(getItem());
@ -487,7 +573,25 @@ const syncDataItem = async (dataId) => {
if (serverItem && serverItem.hash === mergedItem.hash) {
return;
}
await workspaceProvider.uploadData(mergedItem, tooLateChecker(restartContentSyncAfter));
// Upload merged data item
const newSyncData = await workspaceProvider.uploadWorkspaceData(
token,
mergedItem,
syncData,
tooLateChecker(restartContentSyncAfter),
);
// Update sync data if changed
if (newSyncData
&& utils.serializeObject(syncData) !== utils.serializeObject(newSyncData)
) {
store.dispatch('data/patchSyncData', {
[newSyncData.id]: newSyncData,
});
}
// Update data sync data
store.dispatch('data/patchDataSyncData', {
[dataId]: utils.deepCopy(store.getters['data/syncDataByItemId'][dataId]),
});
@ -523,7 +627,7 @@ const syncWorkspace = async () => {
const ifNotTooLate = tooLateChecker(restartSyncAfter);
// Called until no item to save
const saveNextItem = ifNotTooLate(async () => {
const saveNextItem = () => ifNotTooLate(async () => {
const storeItemMap = {
...store.state.file.itemMap,
...store.state.folder.itemMap,
@ -531,16 +635,25 @@ const syncWorkspace = async () => {
...store.state.publishLocation.itemMap,
// Deal with contents and data later
};
const syncDataByItemId = store.getters['data/syncDataByItemId'];
let getSyncData;
if (workspaceProvider.isGit) {
const syncData = store.getters['data/syncData'];
getSyncData = id => syncData[store.getters.itemGitPaths[id]];
} else {
const syncDataByItemId = store.getters['data/syncDataByItemId'];
getSyncData = id => syncDataByItemId[id];
}
const [changedItem, syncDataToUpdate] = utils.someResult(
Object.entries(storeItemMap),
([id, item]) => {
const existingSyncData = syncDataByItemId[id];
const existingSyncData = getSyncData(id);
if ((!existingSyncData || existingSyncData.hash !== item.hash)
// Add file/folder if parent has been added
&& (!storeItemMap[item.parentId] || syncDataByItemId[item.parentId])
&& (!storeItemMap[item.parentId] || getSyncData(item.parentId))
// Add file if content has been added
&& (item.type !== 'file' || syncDataByItemId[`${id}/content`])
&& (item.type !== 'file' || getSyncData(`${id}/content`))
) {
return [item, existingSyncData];
}
@ -550,7 +663,7 @@ const syncWorkspace = async () => {
if (changedItem) {
const resultSyncData = await workspaceProvider
.saveSimpleItem(
.saveWorkspaceItem(
// Use deepCopy to freeze objects
utils.deepCopy(changedItem),
utils.deepCopy(syncDataToUpdate),
@ -565,29 +678,39 @@ const syncWorkspace = async () => {
await saveNextItem();
// Called until no item to remove
const removeNextItem = ifNotTooLate(async () => {
const storeItemMap = {
...store.state.file.itemMap,
...store.state.folder.itemMap,
...store.state.syncLocation.itemMap,
...store.state.publishLocation.itemMap,
...store.state.content.itemMap,
};
const removeNextItem = () => ifNotTooLate(async () => {
let getItem;
let getFileItem;
if (workspaceProvider.isGit) {
const { gitPathItems } = store.getters;
getItem = syncData => gitPathItems[syncData.id];
getFileItem = syncData => gitPathItems[syncData.id.slice(1)]; // Remove leading /
} else {
const { allItemMap } = store.getters;
getItem = syncData => allItemMap[syncData.itemId];
getFileItem = syncData => allItemMap[syncData.itemId.split('/')[0]];
}
const syncData = store.getters['data/syncData'];
const syncDataToRemove = utils.deepCopy(utils.someResult(
Object.values(syncData),
existingSyncData => !storeItemMap[existingSyncData.itemId]
// We don't want to delete data items, especially on first sync
&& existingSyncData.type !== 'data'
// Remove content only if file has been removed
&& (existingSyncData.type !== 'content'
|| !storeItemMap[existingSyncData.itemId.split('/')[0]])
&& existingSyncData,
(existingSyncData) => {
if (!getItem(existingSyncData)
// We don't want to delete data items, especially on first sync
&& existingSyncData.type !== 'data'
// Remove content only if file has been removed
&& (existingSyncData.type !== 'content'
|| !getFileItem(existingSyncData))
) {
return existingSyncData;
}
return null;
},
));
if (syncDataToRemove) {
// Use deepCopy to freeze objects
await workspaceProvider.removeItem(syncDataToRemove, ifNotTooLate);
await workspaceProvider.removeWorkspaceItem(syncDataToRemove, ifNotTooLate);
const syncDataCopy = { ...store.getters['data/syncData'] };
delete syncDataCopy[syncDataToRemove.id];
store.dispatch('data/setSyncData', syncDataCopy);
@ -608,11 +731,22 @@ const syncWorkspace = async () => {
...Object.keys(localDbSvc.hashMap.content),
...store.getters['file/items'].map(file => `${file.id}/content`),
])];
const contentMap = store.state.content.itemMap;
const syncDataById = store.getters['data/syncData'];
let getSyncData;
if (workspaceProvider.isGit) {
const { itemGitPaths } = store.getters;
getSyncData = contentId => syncDataById[itemGitPaths[contentId]];
} else {
const syncDataByItemId = store.getters['data/syncDataByItemId'];
getSyncData = contentId => syncDataByItemId[contentId];
}
return utils.someResult(contentIds, (contentId) => {
// Get content hash from itemMap or from localDbSvc if not loaded
const loadedContent = store.state.content.itemMap[contentId];
const loadedContent = contentMap[contentId];
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
const syncData = store.getters['data/syncDataByItemId'][contentId];
const syncData = getSyncData(contentId);
if (
// Sync if content syncing was not attempted yet
!syncContext.attempted[contentId] &&
@ -721,7 +855,7 @@ const requestSync = () => {
};
intervalId = utils.setInterval(() => attempt(), 1000);
attempt();
return attempt();
});
};

View File

@ -23,7 +23,6 @@ const parseQueryParams = (params) => {
return result;
};
// For utils.computeProperties()
const deepOverride = (obj, opt) => {
if (obj === undefined) {
@ -172,10 +171,12 @@ export default {
return Math.abs(this.hash(this.serializeObject(params))).toString(36);
},
encodeBase64(str, urlSafe = false) {
const result = btoa(encodeURIComponent(str).replace(
const uriEncodedStr = encodeURIComponent(str);
const utf8Str = uriEncodedStr.replace(
/%([0-9A-F]{2})/g,
(match, p1) => String.fromCharCode(`0x${p1}`),
));
);
const result = btoa(utf8Str);
if (!urlSafe) {
return result;
}
@ -187,10 +188,12 @@ export default {
decodeBase64(str) {
// In case of URL safe base64
const sanitizedStr = str.replace(/_/g, '/').replace(/-/g, '+');
return decodeURIComponent(atob(sanitizedStr)
const utf8Str = atob(sanitizedStr);
const uriEncodedStr = utf8Str
.split('')
.map(c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)
.join(''));
.join('');
return decodeURIComponent(uriEncodedStr);
},
computeProperties(yamlProperties) {
let properties = {};

View File

@ -82,7 +82,7 @@ module.actions = {
}) {
const { revisionContent } = state;
if (revisionContent) {
await dispatch('modal/fileRestoration', null, { root: true });
await dispatch('modal/open', 'fileRestoration', { root: true });
// Close revision
commit('setRevisionContent');
const currentContent = utils.deepCopy(getters.current);

View File

@ -26,21 +26,23 @@ export default {
setTimeout(() => {
// Take the size of the context menu and place it
const elt = document.querySelector('.context-menu__inner');
const height = elt.offsetHeight;
if (coordinates.top + height > rootState.layout.bodyHeight) {
coordinates.top -= height;
if (elt) {
const height = elt.offsetHeight;
if (coordinates.top + height > rootState.layout.bodyHeight) {
coordinates.top -= height;
}
if (coordinates.top < 0) {
coordinates.top = 0;
}
const width = elt.offsetWidth;
if (coordinates.left + width > rootState.layout.bodyWidth) {
coordinates.left -= width;
}
if (coordinates.left < 0) {
coordinates.left = 0;
}
commit('setCoordinates', coordinates);
}
if (coordinates.top < 0) {
coordinates.top = 0;
}
const width = elt.offsetWidth;
if (coordinates.left + width > rootState.layout.bodyWidth) {
coordinates.left -= width;
}
if (coordinates.left < 0) {
coordinates.left = 0;
}
commit('setCoordinates', coordinates);
}, 1);
return new Promise(resolve => commit('setResolve', resolve));

View File

@ -135,7 +135,7 @@ export default {
const loginToken = rootGetters['workspace/loginToken'];
if (!loginToken) {
try {
await dispatch('modal/signInForComment', null, { root: true });
await dispatch('modal/open', 'signInForComment', { root: true });
await googleHelper.signin();
syncSvc.requestSync();
await dispatch('createNewDiscussion', selection);

View File

@ -18,11 +18,10 @@ const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
const compare = (node1, node2) => collator.compare(node1.item.name, node2.item.name);
class Node {
constructor(item, locations = [], isFolder = false, isRoot = false) {
constructor(item, locations = [], isFolder = false) {
this.item = item;
this.locations = locations;
this.isFolder = isFolder;
this.isRoot = isRoot;
if (isFolder) {
this.folders = [];
this.files = [];
@ -84,7 +83,8 @@ export default {
},
getters: {
nodeStructure: (state, getters, rootState, rootGetters) => {
const rootNode = new Node(emptyFolder(), [], true, true);
const rootNode = new Node(emptyFolder(), [], true);
rootNode.isRoot = true;
// Create Trash node
const trashFolderNode = new Node(emptyFolder(), [], true);

View File

@ -119,6 +119,41 @@ const store = new Vuex.Store({
});
return result;
},
itemGitPaths: (state, { allItemMap, itemPaths }) => {
const result = {};
Object.entries(allItemMap).forEach(([id, item]) => {
if (item.type === 'data') {
result[id] = `.stackedit-data/${id}.json`;
} else if (item.type === 'file') {
result[id] = `${itemPaths[id]}.md`;
} else if (item.type === 'content') {
const [fileId] = id.split('/');
result[id] = `/${itemPaths[fileId]}.md`;
} else if (item.type === 'folder') {
result[id] = itemPaths[id];
} else if (item.type === 'syncLocation' || item.type === 'publishLocation') {
// locations are stored as paths
const encodedItem = utils.encodeBase64(utils.serializeObject({
...item,
id: undefined,
type: undefined,
fileId: undefined,
}), true);
const extension = item.type === 'syncLocation' ? 'sync' : 'publish';
result[id] = `${itemPaths[item.fileId]}.${encodedItem}.${extension}`;
}
});
return result;
},
gitPathItems: (state, { allItemMap, itemGitPaths }) => {
const result = {};
Object.entries(itemGitPaths).forEach(([id, path]) => {
const items = result[path] || [];
items.push(allItemMap[id]);
result[path] = items;
});
return result;
},
isSponsor: ({ light, monetizeSponsor }, getters) => {
const sponsorToken = getters['workspace/sponsorToken'];
return light || monetizeSponsor || (sponsorToken && sponsorToken.isSponsor);

View File

@ -36,100 +36,5 @@ export default {
commit('setHidden', false);
}
},
folderDeletion: ({ dispatch }, item) => dispatch('open', {
content: `<p>You are about to delete the folder <b>${item.name}</b>. Its files will be moved to Trash. Are you sure?</p>`,
resolveText: 'Yes, delete',
rejectText: 'No',
}),
tempFileDeletion: ({ dispatch }, item) => dispatch('open', {
content: `<p>You are about to permanently delete the temporary file <b>${item.name}</b>. Are you sure?</p>`,
resolveText: 'Yes, delete',
rejectText: 'No',
}),
tempFolderDeletion: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to permanently delete all the temporary files. Are you sure?</p>',
resolveText: 'Yes, delete all',
rejectText: 'No',
}),
discussionDeletion: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to delete a discussion. Are you sure?</p>',
resolveText: 'Yes, delete',
rejectText: 'No',
}),
commentDeletion: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to delete a comment. Are you sure?</p>',
resolveText: 'Yes, delete',
rejectText: 'No',
}),
trashDeletion: ({ dispatch }) => dispatch('open', {
content: '<p>Files in the trash are automatically deleted after 7 days of inactivity.</p>',
rejectText: 'Ok',
}),
fileRestoration: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to revert some changes. Are you sure?</p>',
resolveText: 'Yes, revert',
rejectText: 'No',
}),
unauthorizedName: ({ dispatch }, name) => dispatch('open', {
content: `<p><b>${name}</b> is not an authorized name.</p>`,
rejectText: 'Ok',
}),
stripName: ({ dispatch }, name) => dispatch('open', {
content: `<p><b>${name}</b> contains illegal characters. Do you want to strip them?</p>`,
resolveText: 'Yes, strip',
rejectText: 'No',
}),
pathConflict: ({ dispatch }, name) => dispatch('open', {
content: `<p><b>${name}</b> already exists. Do you want to add a suffix?</p>`,
resolveText: 'Yes, add suffix',
rejectText: 'No',
}),
removeWorkspace: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to remove a workspace locally. Are you sure?</p>',
resolveText: 'Yes, remove',
rejectText: 'No',
}),
reset: ({ dispatch }) => dispatch('open', {
content: '<p>This will clean all your workspaces locally. Are you sure?</p>',
resolveText: 'Yes, clean',
rejectText: 'No',
}),
providerRedirection: ({ dispatch }, { providerName }) => dispatch('open', {
content: `<p>You are about to navigate to the <b>${providerName}</b> authorization page.</p>`,
resolveText: 'Ok, go on',
rejectText: 'Cancel',
}),
workspaceGoogleRedirection: ({ dispatch }) => dispatch('open', {
content: '<p>StackEdit needs full Google Drive access to open this workspace.</p>',
resolveText: 'Ok, grant',
rejectText: 'Cancel',
}),
signInForSponsorship: ({ dispatch }) => dispatch('open', {
type: 'signInForSponsorship',
content: `<p>You have to sign in with Google to sponsor.</p>
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
}),
signInForComment: ({ dispatch }) => dispatch('open', {
content: `<p>You have to sign in with Google to start commenting.</p>
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
}),
signInForHistory: ({ dispatch }) => dispatch('open', {
content: `<p>You have to sign in with Google to enable revision history.</p>
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
}),
sponsorOnly: ({ dispatch }) => dispatch('open', {
content: '<p>This feature is restricted to sponsors as it relies on server resources.</p>',
rejectText: 'Ok, I understand',
}),
paymentSuccess: ({ dispatch }) => dispatch('open', {
content: '<p>Thank you for your payment! Your sponsorship will be active in a minute.</p>',
rejectText: 'Ok',
}),
},
};

View File

@ -1,4 +1,4 @@
const itemTimeout = 5000;
const defaultTimeout = 5000;
export default {
namespaced: true,
@ -14,8 +14,10 @@ export default {
showItem({ state, commit }, item) {
if (state.items.every(other => other.type !== item.type || other.content !== item.content)) {
commit('setItems', [...state.items, item]);
setTimeout(() =>
commit('setItems', state.items.filter(otherItem => otherItem !== item)), itemTimeout);
setTimeout(
() => commit('setItems', state.items.filter(otherItem => otherItem !== item)),
item.timeout || defaultTimeout,
);
}
},
info({ dispatch }, info) {
@ -25,9 +27,7 @@ export default {
});
},
error({ dispatch, rootState }, error) {
const item = {
type: 'error',
};
const item = { type: 'error' };
if (error) {
if (error.message) {
item.content = error.message;

View File

@ -15,6 +15,10 @@
<meta name="google-site-verification" content="iWDn0T2r2_bDQWp_nW23MGePbO9X0M8wQSzbOU70pFQ" />
<link rel="stylesheet" href="https://stackedit.io/style.css">
<style>
body {
background-color: #fbfbfb;
}
* {
box-sizing: border-box;
}
@ -244,7 +248,14 @@
.image {
display: block;
margin: 1em auto;
border: 2px solid #f3f3f3;
border-radius: 2px;
background-color: #fff;
}
.image img {
display: block;
margin: 0.5em auto;
}
</style>
<script>
@ -295,7 +306,9 @@
</div>
</div>
<div class="column">
<img class="image" width="230" src="static/landing/syntax-highlighting.gif">
<div class="image" style="width: 260px">
<img width="230" src="static/landing/syntax-highlighting.gif">
</div>
</div>
</div>
<div class="row">
@ -339,7 +352,7 @@
<div class="column">
<div class="feature">
<h3>Collaborate</h3>
<p>With StackEdit, you can share collaborative workspaces, thanks to the Google Drive synchronization mechanism. If two collaborators are working on the same file at the same time, StackEdit takes care of merging the changes.</p>
<p>With StackEdit, you can share collaborative workspaces, thanks to the synchronization mechanism. If two collaborators are working on the same file at the same time, StackEdit takes care of merging the changes.</p>
</div>
<img class="image" width="300" src="static/landing/workspace.png">
</div>
@ -361,7 +374,9 @@
<div class="row">
<div class="column">
<br>
<img class="image" width="230" src="static/landing/gfm.png">
<div class="image" style="width: 250px">
<img width="230" src="static/landing/gfm.png">
</div>
</div>
<div class="column">
<div class="feature">
@ -373,7 +388,9 @@
<div class="row">
<div class="column">
<br>
<img class="image" width="250" src="static/landing/katex.gif">
<div class="image" style="width: 270px">
<img width="250" src="static/landing/katex.gif">
</div>
</div>
<div class="column">
<div class="feature">
@ -384,7 +401,9 @@
</div>
<div class="row">
<div class="column">
<img class="image" width="280" src="static/landing/mermaid.gif">
<div class="image" style="width: 300px">
<img width="280" src="static/landing/mermaid.gif">
</div>
</div>
<div class="column">
<div class="feature">

8
test/unit/.eslintrc Normal file
View File

@ -0,0 +1,8 @@
{
"env": {
"jest": true
},
"extends": [
"../../.eslintrc.js"
]
}

35
test/unit/jest.conf.js Normal file
View File

@ -0,0 +1,35 @@
const path = require('path');
module.exports = {
rootDir: path.resolve(__dirname, '../../'),
moduleFileExtensions: [
'js',
'json',
'vue',
],
moduleNameMapper: {
'\\.(css|scss)$': 'identity-obj-proxy',
'^!raw-loader!': 'identity-obj-proxy',
'^worker-loader!\\./templateWorker\\.js$': '<rootDir>/test/unit/mocks/templateWorkerMock',
},
transform: {
'^.+\\.js$': '<rootDir>/node_modules/babel-jest',
'.*\\.(vue)$': '<rootDir>/node_modules/vue-jest',
'.*\\.(yml|html|md)$': 'jest-raw-loader',
},
snapshotSerializers: ['<rootDir>/node_modules/jest-serializer-vue'],
setupFiles: [
'<rootDir>/test/unit/setup',
],
coverageDirectory: '<rootDir>/test/unit/coverage',
collectCoverageFrom: [
'src/**/*.{js,vue}',
'!src/main.js',
'!**/node_modules/**',
],
globals: {
GOOGLE_CLIENT_ID: 'GOOGLE_CLIENT_ID',
GITHUB_CLIENT_ID: 'GITHUB_CLIENT_ID',
NODE_ENV: 'production',
},
};

View File

@ -0,0 +1,7 @@
window.crypto = {
getRandomValues(array) {
for (let i = 0; i < array.length; i += 1) {
array[i] = Math.floor(Math.random() * 1000000);
}
},
};

View File

@ -0,0 +1,9 @@
const store = {};
window.localStorage = {
getItem(key) {
return store[key] || null;
},
setItem(key, value) {
store[key] = value.toString();
},
};

View File

@ -0,0 +1,6 @@
/* eslint-disable class-methods-use-this */
class MutationObserver {
observe() {
}
}
window.MutationObserver = MutationObserver;

View File

@ -0,0 +1 @@
module.exports = 'test-file-stub';

5
test/unit/setup.js Normal file
View File

@ -0,0 +1,5 @@
import Vue from 'vue';
import './mocks/cryptoMock';
import './mocks/mutationObserverMock';
Vue.config.productionTip = false;

View File

@ -0,0 +1,41 @@
import ButtonBar from '../../../../src/components/ButtonBar';
import store from '../../../../src/store';
import specUtils from '../specUtils';
describe('ButtonBar.vue', () => {
it('should toggle the navigation bar', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--navigation-bar-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showNavigationBar,
));
it('should toggle the side preview', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--side-preview-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showSidePreview,
));
it('should toggle the editor', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--editor-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showEditor,
));
it('should toggle the focus mode', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--focus-mode-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].focusMode,
));
it('should toggle the scroll sync', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--scroll-sync-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].scrollSync,
));
it('should toggle the status bar', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--status-bar-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showStatusBar,
));
});

View File

@ -0,0 +1,32 @@
import { shallowMount } from '@vue/test-utils';
import ContextMenu from '../../../../src/components/ContextMenu';
import store from '../../../../src/store';
import '../specUtils';
const mount = () => shallowMount(ContextMenu, { store });
describe('ContextMenu.vue', () => {
const name = 'Name';
const makeOptions = () => ({
coordinates: {
left: 0,
top: 0,
},
items: [{ name }],
});
it('should open/close itself', async () => {
const wrapper = mount();
expect(wrapper.contains('.context-menu__item')).toEqual(false);
setTimeout(() => wrapper.find('.context-menu__item').trigger('click'), 1);
const item = await store.dispatch('contextMenu/open', makeOptions());
expect(item.name).toEqual(name);
});
it('should cancel itself', async () => {
const wrapper = mount();
setTimeout(() => wrapper.trigger('click'), 1);
const item = await store.dispatch('contextMenu/open', makeOptions());
expect(item).toEqual(null);
});
});

View File

@ -0,0 +1,186 @@
import { shallowMount } from '@vue/test-utils';
import Explorer from '../../../../src/components/Explorer';
import store from '../../../../src/store';
import fileSvc from '../../../../src/services/fileSvc';
import specUtils from '../specUtils';
const mount = () => shallowMount(Explorer, { store });
const select = (id) => {
store.commit('explorer/setSelectedId', id);
expect(store.getters['explorer/selectedNode'].item.id).toEqual(id);
};
const ensureExists = file => expect(store.getters.allItemMap).toHaveProperty(file.id);
const ensureNotExists = file => expect(store.getters.allItemMap).not.toHaveProperty(file.id);
describe('Explorer.vue', () => {
it('should create new files in the root folder', () => {
expect(store.state.explorer.newChildNode.isNil).toBeTruthy();
const wrapper = mount();
wrapper.find('.side-title__button--new-file').trigger('click');
expect(store.state.explorer.newChildNode.isNil).toBeFalsy();
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'file',
parentId: null,
});
});
it('should create new files in a folder', async () => {
const folder = await fileSvc.storeItem({ type: 'folder' });
const wrapper = mount();
select(folder.id);
wrapper.find('.side-title__button--new-file').trigger('click');
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'file',
parentId: folder.id,
});
});
it('should not create new files in the trash folder', () => {
const wrapper = mount();
select('trash');
wrapper.find('.side-title__button--new-file').trigger('click');
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'file',
parentId: null,
});
});
it('should create new folders in the root folder', () => {
expect(store.state.explorer.newChildNode.isNil).toBeTruthy();
const wrapper = mount();
wrapper.find('.side-title__button--new-folder').trigger('click');
expect(store.state.explorer.newChildNode.isNil).toBeFalsy();
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'folder',
parentId: null,
});
});
it('should create new folders in a folder', async () => {
const folder = await fileSvc.storeItem({ type: 'folder' });
const wrapper = mount();
select(folder.id);
wrapper.find('.side-title__button--new-folder').trigger('click');
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'folder',
parentId: folder.id,
});
});
it('should not create new folders in the trash folder', () => {
const wrapper = mount();
select('trash');
wrapper.find('.side-title__button--new-folder').trigger('click');
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'folder',
parentId: null,
});
});
it('should not create new folders in the temp folder', () => {
const wrapper = mount();
select('temp');
wrapper.find('.side-title__button--new-folder').trigger('click');
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'folder',
parentId: null,
});
});
it('should move file to the trash folder on delete', async () => {
const file = await fileSvc.createFile({}, true);
expect(file.parentId).toEqual(null);
const wrapper = mount();
select(file.id);
wrapper.find('.side-title__button--delete').trigger('click');
ensureExists(file);
expect(file.parentId).toEqual('trash');
});
it('should not delete the trash folder', async () => {
const wrapper = mount();
select('trash');
wrapper.find('.side-title__button--delete').trigger('click');
await specUtils.resolveModal('trashDeletion');
});
it('should not delete files in the trash folder', async () => {
const file = await fileSvc.createFile({ parentId: 'trash' }, true);
const wrapper = mount();
select(file.id);
wrapper.find('.side-title__button--delete').trigger('click');
await specUtils.resolveModal('trashDeletion');
ensureExists(file);
});
it('should delete the temp folder after confirmation', async () => {
const file = await fileSvc.createFile({ parentId: 'temp' }, true);
const wrapper = mount();
select('temp');
wrapper.find('.side-title__button--delete').trigger('click');
await specUtils.resolveModal('tempFolderDeletion');
ensureNotExists(file);
});
it('should delete temp files after confirmation', async () => {
const file = await fileSvc.createFile({ parentId: 'temp' }, true);
const wrapper = mount();
select(file.id);
wrapper.find('.side-title__button--delete').trigger('click');
ensureExists(file);
await specUtils.resolveModal('tempFileDeletion');
ensureNotExists(file);
});
it('should delete folder after confirmation', async () => {
const folder = await fileSvc.storeItem({ type: 'folder' });
const file = await fileSvc.createFile({ parentId: folder.id }, true);
const wrapper = mount();
select(folder.id);
wrapper.find('.side-title__button--delete').trigger('click');
await specUtils.resolveModal('folderDeletion');
ensureNotExists(folder);
// Make sure file has been moved to Trash
ensureExists(file);
expect(file.parentId).toEqual('trash');
});
it('should rename files', async () => {
const file = await fileSvc.createFile({}, true);
const wrapper = mount();
select(file.id);
wrapper.find('.side-title__button--rename').trigger('click');
expect(store.getters['explorer/editingNode'].item.id).toEqual(file.id);
});
it('should rename folders', async () => {
const folder = await fileSvc.storeItem({ type: 'folder' });
const wrapper = mount();
select(folder.id);
wrapper.find('.side-title__button--rename').trigger('click');
expect(store.getters['explorer/editingNode'].item.id).toEqual(folder.id);
});
it('should not rename the trash folder', async () => {
const wrapper = mount();
select('trash');
wrapper.find('.side-title__button--rename').trigger('click');
expect(store.getters['explorer/editingNode'].isNil).toBeTruthy();
});
it('should not rename the temp folder', async () => {
const wrapper = mount();
select('temp');
wrapper.find('.side-title__button--rename').trigger('click');
expect(store.getters['explorer/editingNode'].isNil).toBeTruthy();
});
it('should close itself', async () => {
store.dispatch('data/toggleExplorer', true);
specUtils.checkToggler(
Explorer,
wrapper => wrapper.find('.side-title__button--close').trigger('click'),
() => store.getters['data/layoutSettings'].showExplorer,
);
});
});

View File

@ -0,0 +1,273 @@
import { shallowMount } from '@vue/test-utils';
import ExplorerNode from '../../../../src/components/ExplorerNode';
import store from '../../../../src/store';
import fileSvc from '../../../../src/services/fileSvc';
import explorerSvc from '../../../../src/services/explorerSvc';
import specUtils from '../specUtils';
const makeFileNode = async () => {
const file = await fileSvc.createFile({}, true);
const node = store.getters['explorer/nodeMap'][file.id];
expect(node.item.id).toEqual(file.id);
return node;
};
const makeFolderNode = async () => {
const folder = await fileSvc.storeItem({ type: 'folder' });
const node = store.getters['explorer/nodeMap'][folder.id];
expect(node.item.id).toEqual(folder.id);
return node;
};
const mount = node => shallowMount(ExplorerNode, {
store,
propsData: { node, depth: 1 },
});
const mountAndSelect = (node) => {
const wrapper = mount(node);
wrapper.find('.explorer-node__item').trigger('click');
expect(store.getters['explorer/selectedNode'].item.id).toEqual(node.item.id);
expect(wrapper.classes()).toContain('explorer-node--selected');
return wrapper;
};
const dragAndDrop = (sourceItem, targetItem) => {
const sourceNode = store.getters['explorer/nodeMap'][sourceItem.id];
mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart', {
dataTransfer: { setData: () => {} },
});
expect(store.state.explorer.dragSourceId).toEqual(sourceItem.id);
const targetNode = store.getters['explorer/nodeMap'][targetItem.id];
const wrapper = mount(targetNode);
wrapper.trigger('dragenter');
expect(store.state.explorer.dragTargetId).toEqual(targetItem.id);
wrapper.trigger('drop');
const expectedParentId = targetItem.type === 'file' ? targetItem.parentId : targetItem.id;
expect(store.getters['explorer/selectedNode'].item.parentId).toEqual(expectedParentId);
};
describe('ExplorerNode.vue', () => {
const modifiedName = 'Name';
it('should open files 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);
});
it('should open folders on select after a timeout', async () => {
const node = await makeFolderNode();
const wrapper = mountAndSelect(node);
expect(wrapper.classes()).not.toContain('explorer-node--open');
await new Promise(resolve => setTimeout(resolve, 10));
expect(wrapper.classes()).toContain('explorer-node--open');
});
it('should open folders on new child', async () => {
const node = await makeFolderNode();
const wrapper = mountAndSelect(node);
// Close the folder
wrapper.find('.explorer-node__item').trigger('click');
await new Promise(resolve => setTimeout(resolve, 10));
expect(wrapper.classes()).not.toContain('explorer-node--open');
explorerSvc.newItem();
expect(wrapper.classes()).toContain('explorer-node--open');
});
it('should create new files in a folder', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('New file');
expect(wrapper.contains('.explorer-node__new-child--file')).toBe(true);
store.commit('explorer/setNewItemName', modifiedName);
wrapper.find('.explorer-node__new-child--file .text-input').trigger('blur');
await new Promise(resolve => setTimeout(resolve, 1));
expect(store.getters['explorer/selectedNode'].item).toMatchObject({
name: modifiedName,
type: 'file',
parentId: node.item.id,
});
expect(wrapper.contains('.explorer-node__new-child--file')).toBe(false);
});
it('should cancel a file creation on escape', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('New file');
expect(wrapper.contains('.explorer-node__new-child--file')).toBe(true);
store.commit('explorer/setNewItemName', modifiedName);
wrapper.find('.explorer-node__new-child--file .text-input').trigger('keydown', {
keyCode: 27,
});
await new Promise(resolve => setTimeout(resolve, 1));
expect(store.getters['explorer/selectedNode'].item).not.toMatchObject({
name: 'modifiedName',
type: 'file',
parentId: node.item.id,
});
expect(wrapper.contains('.explorer-node__new-child--file')).toBe(false);
});
it('should not create new files 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 () => {
const node = store.getters['explorer/nodeMap'].trash;
mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New file').disabled).toBe(true);
});
it('should create new folder in folder', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('New folder');
expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(true);
store.commit('explorer/setNewItemName', modifiedName);
wrapper.find('.explorer-node__new-child--folder .text-input').trigger('blur');
await new Promise(resolve => setTimeout(resolve, 1));
expect(store.getters['explorer/selectedNode'].item).toMatchObject({
name: modifiedName,
type: 'folder',
parentId: node.item.id,
});
expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false);
});
it('should cancel a folder creation on escape', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('New folder');
expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(true);
store.commit('explorer/setNewItemName', modifiedName);
wrapper.find('.explorer-node__new-child--folder .text-input').trigger('keydown', {
keyCode: 27,
});
await new Promise(resolve => setTimeout(resolve, 1));
expect(store.getters['explorer/selectedNode'].item).not.toMatchObject({
name: modifiedName,
type: 'folder',
parentId: node.item.id,
});
expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false);
});
it('should not create new folders 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 () => {
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 () => {
const node = store.getters['explorer/nodeMap'].temp;
mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true);
});
it('should rename files', async () => {
const node = await makeFileNode();
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 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 () => {
const node = await makeFileNode();
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);
});
it('should not rename the trash folder', async () => {
const node = store.getters['explorer/nodeMap'].trash;
mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true);
});
it('should not rename the temp folder', async () => {
const node = store.getters['explorer/nodeMap'].temp;
mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true);
});
it('should move a file into a folder', async () => {
const sourceItem = await fileSvc.createFile({}, true);
const targetItem = await fileSvc.storeItem({ type: 'folder' });
dragAndDrop(sourceItem, targetItem);
});
it('should move a folder into a folder', async () => {
const sourceItem = await fileSvc.storeItem({ type: 'folder' });
const targetItem = await fileSvc.storeItem({ type: 'folder' });
dragAndDrop(sourceItem, targetItem);
});
it('should move a file into a file parent folder', async () => {
const targetItem = await fileSvc.storeItem({ type: 'folder' });
const file = await fileSvc.createFile({ parentId: targetItem.id }, true);
const sourceItem = await fileSvc.createFile({}, true);
dragAndDrop(sourceItem, file);
});
it('should not move the trash folder', async () => {
const sourceNode = store.getters['explorer/nodeMap'].trash;
mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart');
expect(store.state.explorer.dragSourceId).not.toEqual('trash');
});
it('should not move the temp folder', async () => {
const sourceNode = store.getters['explorer/nodeMap'].temp;
mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart');
expect(store.state.explorer.dragSourceId).not.toEqual('temp');
});
it('should not move a 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 () => {
const file = await fileSvc.createFile({ parentId: 'temp' }, true);
const targetNode = store.getters['explorer/nodeMap'][file.id];
const wrapper = mount(targetNode);
wrapper.trigger('dragenter');
expect(store.state.explorer.dragTargetId).not.toEqual(file.id);
});
});

View File

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

View File

@ -0,0 +1,38 @@
import { shallowMount } from '@vue/test-utils';
import Notification from '../../../../src/components/Notification';
import store from '../../../../src/store';
import '../specUtils';
const mount = () => shallowMount(Notification, { store });
describe('Notification.vue', () => {
it('should autoclose itself', async () => {
const wrapper = mount();
expect(wrapper.contains('.notification__item')).toBe(false);
store.dispatch('notification/showItem', {
type: 'info',
content: 'Test',
timeout: 10,
});
expect(wrapper.contains('.notification__item')).toBe(true);
await new Promise(resolve => setTimeout(resolve, 10));
expect(wrapper.contains('.notification__item')).toBe(false);
});
it('should show messages from top to bottom', async () => {
const wrapper = mount();
store.dispatch('notification/info', 'Test 1');
store.dispatch('notification/info', 'Test 2');
const items = wrapper.findAll('.notification__item');
expect(items.length).toEqual(2);
expect(items.at(0).text()).toMatch(/Test 1/);
expect(items.at(1).text()).toMatch(/Test 2/);
});
it('should not open the same message twice', async () => {
const wrapper = mount();
store.dispatch('notification/info', 'Test');
store.dispatch('notification/info', 'Test');
expect(wrapper.findAll('.notification__item').length).toEqual(1);
});
});

View File

@ -0,0 +1,51 @@
import { shallowMount } from '@vue/test-utils';
import store from '../../../src/store';
import utils from '../../../src/services/utils';
import '../../../src/icons';
import '../../../src/components/common/globals';
const clone = object => JSON.parse(JSON.stringify(object));
const deepAssign = (target, origin) => {
Object.entries(origin).forEach(([key, value]) => {
const type = Object.prototype.toString.call(value);
if (type === '[object Object]' && Object.keys(value).length) {
deepAssign(target[key], value);
} else {
target[key] = value;
}
});
};
const freshState = clone(store.state);
beforeEach(() => {
// Restore store state before each test
deepAssign(store.state, clone(freshState));
});
export default {
checkToggler(Component, toggler, checker) {
const wrapper = shallowMount(Component, { store });
const valueBefore = checker();
toggler(wrapper);
const valueAfter = checker();
expect(valueAfter).toEqual(!valueBefore);
},
async resolveModal(type) {
const config = store.getters['modal/config'];
expect(config).toBeTruthy();
expect(config.type).toEqual(type);
config.resolve();
await new Promise(resolve => setTimeout(resolve, 1));
},
getContextMenuItem(name) {
return utils.someResult(store.state.contextMenu.items, item => item.name === name && item);
},
async resolveContextMenu(name) {
const item = this.getContextMenuItem(name);
expect(item).toBeTruthy();
store.state.contextMenu.resolve(item);
await new Promise(resolve => setTimeout(resolve, 1));
}
};