diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/SavingIndicator.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/SavingIndicator.vue
index 682a58d6c7..09a43210b0 100644
--- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/SavingIndicator.vue
+++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/SavingIndicator.vue
@@ -27,12 +27,12 @@
props: {
nodeIds: {
type: Array,
- default: () => [],
+ default: null,
},
},
data() {
return {
- hasChanges: false,
+ hasNodeChanges: false,
isSaving: false,
lastSaved: null,
lastSavedText: '',
@@ -43,6 +43,13 @@
computed: {
...mapGetters('file', ['getContentNodeFiles']),
...mapGetters('assessmentItem', ['getAssessmentItems']),
+ ...mapGetters(['areAllChangesSaved']),
+ hasChanges() {
+ if (this.nodeIds) {
+ return this.hasNodeChanges;
+ }
+ return !this.areAllChangesSaved;
+ },
},
watch: {
hasChanges(hasChanges) {
@@ -62,21 +69,27 @@
},
},
mounted() {
- this.interval = setInterval(() => {
- const files = flatten(this.nodeIds.map(this.getContentNodeFiles));
- const assessmentItems = flatten(this.nodeIds.map(this.getAssessmentItems));
- this.checkSavingProgress({
- contentNodeIds: this.nodeIds,
- fileIds: files.map(f => f.id).filter(Boolean),
- assessmentIds: assessmentItems
- .map(ai => [ai.contentnode, ai.assessment_id])
- .filter(Boolean),
- }).then(hasChanges => (this.hasChanges = hasChanges));
- }, CHECK_SAVE_INTERVAL);
+ if (this.nodeIds) {
+ this.interval = setInterval(() => {
+ const files = flatten(this.nodeIds.map(this.getContentNodeFiles));
+ const assessmentItems = flatten(this.nodeIds.map(this.getAssessmentItems));
+ this.checkSavingProgress({
+ contentNodeIds: this.nodeIds,
+ fileIds: files.map(f => f.id).filter(Boolean),
+ assessmentIds: assessmentItems
+ .map(ai => [ai.contentnode, ai.assessment_id])
+ .filter(Boolean),
+ }).then(hasChanges => (this.hasNodeChanges = hasChanges));
+ }, CHECK_SAVE_INTERVAL);
+ }
},
beforeDestroy() {
- clearInterval(this.interval);
- clearInterval(this.updateLastSavedInterval);
+ if (this.interval) {
+ clearInterval(this.interval);
+ }
+ if (this.updateLastSavedInterval) {
+ clearInterval(this.updateLastSavedInterval);
+ }
},
methods: {
...mapActions('contentNode', ['checkSavingProgress']),
diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue
index 222cb73ebc..5566b290a0 100644
--- a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue
+++ b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue
@@ -55,6 +55,7 @@
+
- import { mapActions, mapGetters } from 'vuex';
+ import { mapActions, mapGetters, mapState } from 'vuex';
import Clipboard from '../../components/Clipboard';
import SyncResourcesModal from '../sync/SyncResourcesModal';
import ProgressModal from '../progress/ProgressModal';
import PublishModal from '../../components/publish/PublishModal';
+ import SavingIndicator from '../../components/edit/SavingIndicator';
import { DraggableRegions, DraggableUniverses, RouteNames } from '../../constants';
import MainNavigationDrawer from 'shared/views/MainNavigationDrawer';
import IconButton from 'shared/views/IconButton';
@@ -340,6 +342,7 @@
ContentNodeIcon,
DraggablePlaceholder,
MessageDialog,
+ SavingIndicator,
},
mixins: [titleMixin],
props: {
@@ -360,6 +363,9 @@
};
},
computed: {
+ ...mapState({
+ offline: state => !state.connection.online,
+ }),
...mapGetters('contentNode', ['getContentNode']),
...mapGetters('currentChannel', ['currentChannel', 'canEdit', 'canManage', 'rootId']),
rootNode() {
diff --git a/contentcuration/contentcuration/frontend/shared/__tests__/app.spec.js b/contentcuration/contentcuration/frontend/shared/__tests__/app.spec.js
index bf5625df7c..2a95b653f1 100644
--- a/contentcuration/contentcuration/frontend/shared/__tests__/app.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/__tests__/app.spec.js
@@ -15,6 +15,7 @@ const USER_2 = { id: 1 };
describe('startApp', () => {
let store;
+ let cleanup;
beforeEach(() => {
jest.clearAllMocks();
@@ -24,6 +25,10 @@ describe('startApp', () => {
afterEach(async () => {
global.user = undefined;
await Session.table.clear();
+ if (cleanup) {
+ cleanup();
+ }
+ cleanup = undefined;
});
describe('for a guest', () => {
@@ -38,7 +43,7 @@ describe('startApp', () => {
});
it("the client database shouldn't be reset", async () => {
- await startApp({ router, store });
+ cleanup = await startApp({ router, store });
expect(resetDB).not.toHaveBeenCalled();
});
});
@@ -52,7 +57,7 @@ describe('startApp', () => {
});
it('the client database should be reset', async () => {
- await startApp({ router, store });
+ cleanup = await startApp({ router, store });
expect(resetDB).toHaveBeenCalledTimes(1);
});
});
@@ -70,7 +75,7 @@ describe('startApp', () => {
});
it("the client database shouldn't be reset", async () => {
- await startApp({ router, store });
+ cleanup = await startApp({ router, store });
expect(resetDB).not.toHaveBeenCalled();
});
});
@@ -84,7 +89,7 @@ describe('startApp', () => {
});
it("the client database shouldn't be reset", async () => {
- await startApp({ router, store });
+ cleanup = await startApp({ router, store });
expect(resetDB).not.toHaveBeenCalled();
});
});
@@ -98,7 +103,7 @@ describe('startApp', () => {
});
it('the client database should be reset', async () => {
- await startApp({ router, store });
+ cleanup = await startApp({ router, store });
expect(resetDB).toHaveBeenCalledTimes(1);
});
});
diff --git a/contentcuration/contentcuration/frontend/shared/app.js b/contentcuration/contentcuration/frontend/shared/app.js
index 071e0c0813..1285ca6c07 100644
--- a/contentcuration/contentcuration/frontend/shared/app.js
+++ b/contentcuration/contentcuration/frontend/shared/app.js
@@ -1,4 +1,5 @@
import 'regenerator-runtime/runtime';
+import { liveQuery } from 'dexie';
import * as Sentry from '@sentry/vue';
import Vue from 'vue';
import VueRouter from 'vue-router';
@@ -115,6 +116,7 @@ import { i18nSetup } from 'shared/i18n';
import './styles/vuetify.css';
import 'shared/styles/main.less';
import Base from 'shared/Base.vue';
+import urls from 'shared/urls';
import ActionLink from 'shared/views/ActionLink';
import Menu from 'shared/views/Menu';
import { initializeDB, resetDB } from 'shared/data';
@@ -315,8 +317,27 @@ export default async function startApp({ store, router, index }) {
) {
await resetDB();
}
+
+ let subscription;
+
if (currentUser.id !== undefined && currentUser.id !== null) {
+ // The user is logged on, so persist that to the session table in indexeddb
await store.dispatch('saveSession', currentUser, { root: true });
+ // Also watch in case the user logs out, then we should redirect to the login page
+ const observable = liveQuery(() => {
+ return Session.table.toCollection().first(Boolean);
+ });
+
+ subscription = observable.subscribe({
+ next(result) {
+ if (!result && !window.location.pathname.endsWith(urls.accounts())) {
+ window.location = urls.accounts();
+ }
+ },
+ error() {
+ subscription.unsubscribe();
+ },
+ });
}
await Session.setChannelScope();
@@ -350,5 +371,16 @@ export default async function startApp({ store, router, index }) {
// to the session state.
injectVuexStore(store);
+ // Start listening for unsynced change events in IndexedDB
+ store.listenForIndexedDBChanges();
+
rootVue = new Vue(config);
+
+ // Return a cleanup function
+ return function() {
+ if (subscription) {
+ subscription.unsubscribe();
+ }
+ store.stopListeningForIndexedDBChanges();
+ };
}
diff --git a/contentcuration/contentcuration/frontend/shared/data/index.js b/contentcuration/contentcuration/frontend/shared/data/index.js
index 380e09f9c1..1baed3d8d5 100644
--- a/contentcuration/contentcuration/frontend/shared/data/index.js
+++ b/contentcuration/contentcuration/frontend/shared/data/index.js
@@ -37,18 +37,10 @@ if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined') {
window.resetDB = resetDB;
}
-function applyResourceListener(change) {
- const resource = INDEXEDDB_RESOURCES[change.table];
- if (resource && resource.listeners && resource.listeners[change.type]) {
- resource.listeners[change.type](change);
- }
-}
-
export async function initializeDB() {
try {
setupSchema();
await db.open();
- db.on('changes', changes => changes.map(applyResourceListener));
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopSyncing();
diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js
index 3843753b6b..008883fa18 100644
--- a/contentcuration/contentcuration/frontend/shared/data/resources.js
+++ b/contentcuration/contentcuration/frontend/shared/data/resources.js
@@ -214,7 +214,6 @@ class IndexedDBResource {
uuid = true,
indexFields = [],
syncable = false,
- listeners = {},
...options
} = {}) {
this.tableName = tableName;
@@ -228,9 +227,6 @@ class IndexedDBResource {
copyProperties(this, options);
// By default these resources do not sync changes to the backend.
this.syncable = syncable;
- // An object for listening to specific change events on this resource in order to
- // allow for side effects from changes - should be a map of change type to handler function.
- this.listeners = listeners;
}
get table() {
@@ -977,13 +973,6 @@ export const Session = new IndexedDBResource({
tableName: TABLE_NAMES.SESSION,
idField: CURRENT_USER,
uuid: false,
- listeners: {
- [CHANGE_TYPES.DELETED]: function() {
- if (!window.location.pathname.endsWith(urls.accounts())) {
- window.location = urls.accounts();
- }
- },
- },
get currentChannel() {
return window.CHANNEL_EDIT_GLOBAL || {};
},
diff --git a/contentcuration/contentcuration/frontend/shared/vuex/syncProgressPlugin/index.js b/contentcuration/contentcuration/frontend/shared/vuex/syncProgressPlugin/index.js
index 60a2324d2e..0943920c24 100644
--- a/contentcuration/contentcuration/frontend/shared/vuex/syncProgressPlugin/index.js
+++ b/contentcuration/contentcuration/frontend/shared/vuex/syncProgressPlugin/index.js
@@ -1,3 +1,4 @@
+import { liveQuery } from 'dexie';
import syncProgressModule from './syncProgressModule';
import db from 'shared/data/db';
import { CHANGES_TABLE } from 'shared/data/constants';
@@ -5,18 +6,23 @@ import { CHANGES_TABLE } from 'shared/data/constants';
const SyncProgressPlugin = store => {
store.registerModule('syncProgress', syncProgressModule);
- db.on('changes', function(changes) {
- const changesTableUpdated = changes.some(change => change.table === CHANGES_TABLE);
- if (!changesTableUpdated) {
- return;
- }
+ store.listenForIndexedDBChanges = () => {
+ const observable = liveQuery(() => {
+ return db[CHANGES_TABLE].toCollection()
+ .filter(c => !c.synced)
+ .first(Boolean);
+ });
- db[CHANGES_TABLE].toCollection()
- .filter(c => !c.synced)
- .limit(1)
- .count()
- .then(count => store.commit('SET_UNSAVED_CHANGES', count > 0));
- });
+ const subscription = observable.subscribe({
+ next(result) {
+ store.commit('SET_UNSAVED_CHANGES', result);
+ },
+ error() {
+ subscription.unsubscribe();
+ },
+ });
+ store.stopListeningForIndexedDBChanges = subscription.unsubscribe;
+ };
};
export default SyncProgressPlugin;