Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
68 changes: 51 additions & 17 deletions src/view/ElpxViewPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,38 @@
</svg>
<span>{{ t('exelearning', 'Edit') }}</span>
</button>
<button v-else-if="canShowSaveButton"
type="button"
class="exelearning-view-page__action"
:disabled="saving"
:title="t('exelearning', 'Save to Nextcloud')"
@click="save">
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="20"
height="20"
fill="currentColor">
<path d="M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z" />
</svg>
<span>{{ saving ? t('exelearning', 'Saving…') : t('exelearning', 'Save') }}</span>
</button>
<template v-else-if="canShowSaveButton">
<button type="button"
class="exelearning-view-page__action"
data-action="save"
:disabled="saving"
:title="t('exelearning', 'Save to Nextcloud')"
@click="save">
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="20"
height="20"
fill="currentColor">
<path d="M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z" />
</svg>
<span>{{ saving ? t('exelearning', 'Saving…') : t('exelearning', 'Save') }}</span>
</button>
<button type="button"
class="exelearning-view-page__action exelearning-view-page__action--secondary"
data-action="save-and-close"
:disabled="saving"
:title="t('exelearning', 'Save to Nextcloud and go back to Files')"
@click="saveAndClose">
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="20"
height="20"
fill="currentColor">
<path d="M17 7L15.59 8.41L18.17 11H8V13H18.17L15.59 15.58L17 17L22 12M4 5H12V3H4C2.9 3 2 3.9 2 5V19C2 20.1 2.9 21 4 21H12V19H4V5Z" />
</svg>
<span>{{ t('exelearning', 'Save & Close') }}</span>
</button>
</template>
</header>
<div class="exelearning-view-page__body">
<ElpxViewer v-if="file && mode === 'preview'"
Expand Down Expand Up @@ -150,9 +167,13 @@ export default defineComponent({
}
}, 4000)
},
async save(): Promise<void> {
/**
* Saves the open package. Resolves `true` only when the write
* succeeded; a call while another save is in flight is ignored.
*/
async save(): Promise<boolean> {
const embed = this.$refs.editor as InstanceType<typeof EditorEmbed> | undefined
if (!embed) return
if (!embed || this.saving) return false
this.saving = true
this.saveStatus = t('exelearning', 'Saving…')
try {
Expand All @@ -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<void> {
// 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)
}
},
},
})
</script>
Expand Down Expand Up @@ -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;
Expand Down
102 changes: 102 additions & 0 deletions tests/js/elpx-view-page.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>>()
const EditorEmbedStub = defineComponent({
name: 'EditorEmbed',
methods: { save: () => embedSave() },
template: '<div />',
})

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<typeof vi.fn>
let alert: ReturnType<typeof vi.fn>

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<void>((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<void>((resolve) => { finishSave = resolve }))
const page = mountEditor()
const vm = page.vm as unknown as { save: () => Promise<boolean>, saveAndClose: () => Promise<void> }

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()
})
})
2 changes: 2 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
Loading