diff --git a/package-lock.json b/package-lock.json
index c031c3b..939125e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,6 +23,7 @@
"@nextcloud/eslint-config": "^9.0.1",
"@nextcloud/vite-config": "^2.5.4",
"@types/node": "^26.6.1",
+ "@vitejs/plugin-vue": "^6.0.9",
"@vitest/coverage-v8": "^5.0.1",
"@vue/compiler-sfc": "^3.5.0",
"@vue/test-utils": "^2.5.1",
diff --git a/package.json b/package.json
index abcc20e..a491a9b 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,8 @@
"@nextcloud/eslint-config": "^9.0.1",
"@nextcloud/vite-config": "^2.5.4",
"@types/node": "^26.6.1",
+ "@vitejs/plugin-vue": "^6.0.9",
+ "@vitest/coverage-v8": "^5.0.1",
"@vue/compiler-sfc": "^3.5.0",
"@vue/test-utils": "^2.5.1",
"eslint": "^10.11.0",
@@ -39,8 +41,7 @@
"typescript": "^6.0.3",
"vite": "^7.3.6",
"vitest": "^5.0.1",
- "vue-tsc": "^3.3.11",
- "@vitest/coverage-v8": "^5.0.1"
+ "vue-tsc": "^3.3.11"
},
"browserslist": [
"extends @nextcloud/browserslist-config"
diff --git a/src/view/ElpxViewPage.vue b/src/view/ElpxViewPage.vue
index c725474..e1cb829 100644
--- a/src/view/ElpxViewPage.vue
+++ b/src/view/ElpxViewPage.vue
@@ -24,21 +24,38 @@
{{ t('exelearning', 'Edit') }}
-
+
+
+
+
{
+ /**
+ * Saves the open package. Resolves `true` only when the write
+ * succeeded; a call while another save is in flight is ignored.
+ */
+ async save(): Promise {
const embed = this.$refs.editor as InstanceType | undefined
- if (!embed) return
+ if (!embed || this.saving) return false
this.saving = true
this.saveStatus = t('exelearning', 'Saving…')
try {
@@ -163,14 +184,23 @@ export default defineComponent({
this.saveStatus = ''
}
}, 3000)
+ return true
} catch (error) {
this.saveStatus = ''
const message = error instanceof Error ? error.message : String(error)
window.alert(t('exelearning', 'Save failed: {error}', { error: message }))
+ return false
} finally {
this.saving = false
}
},
+ async saveAndClose(): Promise {
+ // Leave only after a confirmed write; a failed save keeps the
+ // editor open so nothing is lost.
+ if (await this.save()) {
+ window.location.assign(this.filesUrl)
+ }
+ },
},
})
@@ -242,6 +272,10 @@ export default defineComponent({
cursor: not-allowed;
}
.exelearning-view-page__action:hover:not([disabled]) { filter: brightness(1.05); }
+.exelearning-view-page__action--secondary {
+ background: transparent;
+ color: var(--color-primary-element, #0a629a);
+}
.exelearning-view-page__body {
flex: 1;
display: flex;
diff --git a/tests/js/elpx-view-page.test.ts b/tests/js/elpx-view-page.test.ts
new file mode 100644
index 0000000..d8a649a
--- /dev/null
+++ b/tests/js/elpx-view-page.test.ts
@@ -0,0 +1,102 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent } from 'vue'
+import { flushPromises, mount } from '@vue/test-utils'
+
+vi.mock('@nextcloud/router', () => ({ generateUrl: (path: string) => path }))
+vi.mock('@nextcloud/l10n', () => ({ translate: (_app: string, text: string) => text }))
+
+const { default: ElpxViewPage } = await import('../../src/view/ElpxViewPage.vue')
+
+const embedSave = vi.fn<() => Promise>()
+const EditorEmbedStub = defineComponent({
+ name: 'EditorEmbed',
+ methods: { save: () => embedSave() },
+ template: '',
+})
+
+function mountEditor() {
+ return mount(ElpxViewPage, {
+ props: {
+ file: { id: 7, name: 'lesson.elpx', path: '/lesson.elpx', mtime: 0, etag: 'e', writable: true },
+ editorAvailable: true,
+ initialMode: 'editor',
+ },
+ global: { stubs: { EditorEmbed: EditorEmbedStub, ElpxViewer: true } },
+ })
+}
+
+let assign: ReturnType
+let alert: ReturnType
+
+beforeEach(() => {
+ embedSave.mockReset()
+ assign = vi.fn()
+ vi.stubGlobal('location', { ...window.location, assign, href: 'http://localhost/apps/exelearning/view' })
+ alert = vi.fn()
+ vi.stubGlobal('alert', alert)
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('ElpxViewPage save actions', () => {
+ it('Save writes the package and keeps the editor open', async () => {
+ embedSave.mockResolvedValue()
+ const page = mountEditor()
+
+ await page.get('[data-action="save"]').trigger('click')
+ await flushPromises()
+
+ expect(embedSave).toHaveBeenCalledTimes(1)
+ expect(assign).not.toHaveBeenCalled()
+ expect(page.findComponent(EditorEmbedStub).exists()).toBe(true)
+ })
+
+ it('Save & Close saves first and returns to Files only afterwards', async () => {
+ let finishSave: () => void = () => undefined
+ embedSave.mockReturnValue(new Promise((resolve) => { finishSave = resolve }))
+ const page = mountEditor()
+
+ await page.get('[data-action="save-and-close"]').trigger('click')
+ expect(embedSave).toHaveBeenCalledTimes(1)
+ expect(assign).not.toHaveBeenCalled()
+
+ finishSave()
+ await flushPromises()
+
+ expect(assign).toHaveBeenCalledWith('/apps/files')
+ })
+
+ it('Save & Close stays in the editor when saving fails', async () => {
+ embedSave.mockRejectedValue(new Error('412 Precondition Failed'))
+ const page = mountEditor()
+
+ await page.get('[data-action="save-and-close"]').trigger('click')
+ await flushPromises()
+
+ expect(assign).not.toHaveBeenCalled()
+ expect(alert).toHaveBeenCalledWith('Save failed: {error}')
+ expect(page.get('[data-action="save"]').attributes('disabled')).toBeUndefined()
+ })
+
+ it('ignores repeated clicks while a save is in flight', async () => {
+ let finishSave: () => void = () => undefined
+ embedSave.mockReturnValue(new Promise((resolve) => { finishSave = resolve }))
+ const page = mountEditor()
+ const vm = page.vm as unknown as { save: () => Promise, saveAndClose: () => Promise }
+
+ const first = vm.save()
+ await page.vm.$nextTick()
+ expect(page.get('[data-action="save"]').attributes('disabled')).toBeDefined()
+ expect(page.get('[data-action="save-and-close"]').attributes('disabled')).toBeDefined()
+ // Bypass the disabled buttons: the guard must hold for Ctrl+S too.
+ expect(await vm.save()).toBe(false)
+ await vm.saveAndClose()
+
+ finishSave()
+ expect(await first).toBe(true)
+ expect(embedSave).toHaveBeenCalledTimes(1)
+ expect(assign).not.toHaveBeenCalled()
+ })
+})
diff --git a/vitest.config.ts b/vitest.config.ts
index 0433cd0..ae706df 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,7 +1,9 @@
import { defineConfig } from 'vitest/config'
+import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig({
+ plugins: [vue()],
test: {
globals: true,
environment: 'happy-dom',