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',