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;