diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 2a6acf056f..b024f13ee7 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -8,11 +8,13 @@ namespace OCA\Libresign; +use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Service\SignerElementsService; use OCP\App\IAppManager; use OCP\Capabilities\IPublicCapability; +use OCP\IAppConfig; /** * @psalm-import-type LibresignCapabilities from ResponseDefinitions @@ -27,6 +29,7 @@ public function __construct( protected SignatureTextService $signatureTextService, protected IAppManager $appManager, protected EnvelopeService $envelopeService, + protected IAppConfig $appConfig, ) { } @@ -40,6 +43,7 @@ public function getCapabilities(): array { $capabilities = [ 'features' => self::FEATURES, 'config' => [ + 'show-confetti' => $this->appConfig->getValueBool(Application::APP_ID, 'show_confetti_after_signing', true), 'sign-elements' => [ 'is-available' => $this->signerElementsService->isSignElementsAvailable(), 'can-create-signature' => $this->signerElementsService->canCreateSignature(), diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 519e222f2a..a99123e43f 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -392,6 +392,7 @@ * @psalm-type LibresignCapabilities = array{ * features: list, * config: array{ + * show-confetti: bool, * sign-elements: array{ * is-available: bool, * can-create-signature: bool, diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index e056322968..e8bbe90418 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -88,6 +88,7 @@ public function getForm(): TemplateResponse { $this->initialState->provideInitialState('approval_group', $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', ['admin'])); $this->initialState->provideInitialState('envelope_enabled', $this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true)); $this->initialState->provideInitialState('parallel_workers', $this->appConfig->getValueString(Application::APP_ID, 'parallel_workers', '4')); + $this->initialState->provideInitialState('show_confetti_after_signing', $this->appConfig->getValueBool(Application::APP_ID, 'show_confetti_after_signing', true)); return new TemplateResponse(Application::APP_ID, 'admin_settings'); } diff --git a/openapi-administration.json b/openapi-administration.json index 81557cbc79..8e75f42a87 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -37,11 +37,15 @@ "config": { "type": "object", "required": [ + "show-confetti", "sign-elements", "envelope", "upload" ], "properties": { + "show-confetti": { + "type": "boolean" + }, "sign-elements": { "type": "object", "required": [ diff --git a/openapi-full.json b/openapi-full.json index a21fe1a024..200bae017c 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -37,11 +37,15 @@ "config": { "type": "object", "required": [ + "show-confetti", "sign-elements", "envelope", "upload" ], "properties": { + "show-confetti": { + "type": "boolean" + }, "sign-elements": { "type": "object", "required": [ diff --git a/openapi.json b/openapi.json index 2fa2516699..3030a0e4c6 100644 --- a/openapi.json +++ b/openapi.json @@ -37,11 +37,15 @@ "config": { "type": "object", "required": [ + "show-confetti", "sign-elements", "envelope", "upload" ], "properties": { + "show-confetti": { + "type": "boolean" + }, "sign-elements": { "type": "object", "required": [ diff --git a/src/tests/views/Settings/Confetti.spec.ts b/src/tests/views/Settings/Confetti.spec.ts new file mode 100644 index 0000000000..5aed6194b1 --- /dev/null +++ b/src/tests/views/Settings/Confetti.spec.ts @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +const loadStateMock = vi.fn() + +vi.mock('@nextcloud/initial-state', () => ({ + loadState: (...args: unknown[]) => loadStateMock(...args), +})) + +vi.mock('@nextcloud/l10n', () => ({ + t: vi.fn((_app: string, text: string) => text), + translate: vi.fn((_app: string, text: string) => text), + translatePlural: vi.fn((_app: string, singular: string, plural: string, count: number) => (count === 1 ? singular : plural)), + n: vi.fn((_app: string, singular: string, plural: string, count: number) => (count === 1 ? singular : plural)), + isRTL: vi.fn(() => false), + getLanguage: vi.fn(() => 'en'), + getLocale: vi.fn(() => 'en'), +})) + +let Confetti: unknown + +beforeAll(async () => { + ;({ default: Confetti } = await import('../../../views/Settings/Confetti.vue')) +}) + +describe('Confetti', () => { + beforeEach(() => { + loadStateMock.mockReset() + }) + + it('defaults to true when state is not set', async () => { + loadStateMock.mockImplementation((_app: string, key: string, fallback: unknown) => fallback) + + const wrapper = mount(Confetti as never, { + global: { + stubs: { + NcSettingsSection: { template: '
' }, + NcCheckboxRadioSwitch: { template: '
' }, + }, + }, + }) + + expect(wrapper.vm.showConfetti).toBe(true) + }) + + it('reads show_confetti_after_signing from initial state', async () => { + loadStateMock.mockImplementation((_app: string, key: string, fallback: unknown) => { + if (key === 'show_confetti_after_signing') return true + return fallback + }) + + const wrapper = mount(Confetti as never, { + global: { + stubs: { + NcSettingsSection: { template: '
' }, + NcCheckboxRadioSwitch: { template: '
' }, + }, + }, + }) + + expect(wrapper.vm.showConfetti).toBe(true) + }) + + it('calls OCP.AppConfig.setValue with "1" when enabled', async () => { + loadStateMock.mockImplementation((_app: string, key: string, fallback: unknown) => fallback) + const setValueMock = vi.fn() + vi.stubGlobal('OCP', { AppConfig: { setValue: setValueMock } }) + + const wrapper = mount(Confetti as never, { + global: { + stubs: { + NcSettingsSection: { template: '
' }, + NcCheckboxRadioSwitch: { template: '
' }, + }, + }, + }) + + await wrapper.setData({ showConfetti: true }) + wrapper.vm.saveShowConfetti() + + expect(setValueMock).toHaveBeenCalledWith('libresign', 'show_confetti_after_signing', '1') + }) + + it('calls OCP.AppConfig.setValue with "0" when disabled', async () => { + loadStateMock.mockImplementation((_app: string, key: string, fallback: unknown) => { + if (key === 'show_confetti_after_signing') return true + return fallback + }) + const setValueMock = vi.fn() + vi.stubGlobal('OCP', { AppConfig: { setValue: setValueMock } }) + + const wrapper = mount(Confetti as never, { + global: { + stubs: { + NcSettingsSection: { template: '
' }, + NcCheckboxRadioSwitch: { template: '
' }, + }, + }, + }) + + await wrapper.setData({ showConfetti: false }) + wrapper.vm.saveShowConfetti() + + expect(setValueMock).toHaveBeenCalledWith('libresign', 'show_confetti_after_signing', '0') + }) +}) diff --git a/src/tests/views/Validation.spec.ts b/src/tests/views/Validation.spec.ts index 0165677a7b..a21de8736b 100644 --- a/src/tests/views/Validation.spec.ts +++ b/src/tests/views/Validation.spec.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, beforeEach, vi } from 'vitest' import { shallowMount } from '@vue/test-utils' import axios from '@nextcloud/axios' +import { getCapabilities } from '@nextcloud/capabilities' import JSConfetti from 'js-confetti' import Validation from '../../views/Validation.vue' @@ -97,6 +98,17 @@ const mockRouter = { push: vi.fn(), } +// Mock capabilities - show-confetti enabled by default so existing tests pass +vi.mock('@nextcloud/capabilities', () => ({ + getCapabilities: vi.fn(() => ({ + libresign: { + config: { + 'show-confetti': true, + }, + }, + })), +})) + // Mock initial state vi.mock('@nextcloud/initial-state', () => ({ loadState: vi.fn((app, key, defaultValue) => defaultValue), @@ -647,6 +659,19 @@ describe('Validation.vue - Business Logic', () => { wrapper.vm.handleValidationSuccess({ status: SIGNED_STATUS, signers: [] }) expect(mockAddConfetti).not.toHaveBeenCalled() }) + + it('does not fire confetti when show-confetti capability is disabled', async () => { + vi.mocked(getCapabilities).mockReturnValueOnce({ + libresign: { + config: { + 'show-confetti': false, + }, + }, + } as ReturnType) + vi.spyOn(wrapper.vm, 'isAfterSigned', 'get').mockReturnValue(true) + wrapper.vm.handleValidationSuccess({ status: SIGNED_STATUS, signers: [] }) + expect(mockAddConfetti).not.toHaveBeenCalled() + }) }) describe('handleSigningComplete method', () => { diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index e4cc84fa82..f8b711aaa0 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -492,6 +492,7 @@ export type components = { Capabilities: { features: string[]; config: { + "show-confetti": boolean; "sign-elements": { "is-available": boolean; "can-create-signature": boolean; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 61948ed532..c0b4467086 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1527,6 +1527,7 @@ export type components = { Capabilities: { features: string[]; config: { + "show-confetti": boolean; "sign-elements": { "is-available": boolean; "can-create-signature": boolean; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 222ead5fa6..1232192bf4 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1046,6 +1046,7 @@ export type components = { Capabilities: { features: string[]; config: { + "show-confetti": boolean; "sign-elements": { "is-available": boolean; "can-create-signature": boolean; diff --git a/src/views/Settings/Confetti.vue b/src/views/Settings/Confetti.vue new file mode 100644 index 0000000000..4f95e9deaf --- /dev/null +++ b/src/views/Settings/Confetti.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/views/Settings/Settings.vue b/src/views/Settings/Settings.vue index 69c2108a98..8310d137a3 100644 --- a/src/views/Settings/Settings.vue +++ b/src/views/Settings/Settings.vue @@ -27,6 +27,7 @@ + @@ -51,6 +52,7 @@ import SignatureFlow from './SignatureFlow.vue' import SignatureHashAlgorithm from './SignatureHashAlgorithm.vue' import SignatureStamp from './SignatureStamp.vue' import SigningMode from './SigningMode.vue' +import Confetti from './Confetti.vue' import Envelope from './Envelope.vue' import SupportProject from './SupportProject.vue' import TSA from './TSA.vue' @@ -76,6 +78,7 @@ export default { SignatureFlow, SignatureHashAlgorithm, SignatureStamp, + Confetti, SigningMode, Envelope, SupportProject, diff --git a/src/views/Validation.vue b/src/views/Validation.vue index 6913088999..3049f25582 100644 --- a/src/views/Validation.vue +++ b/src/views/Validation.vue @@ -86,6 +86,7 @@ import { } from '@mdi/js' import JSConfetti from 'js-confetti' import axios from '@nextcloud/axios' +import { getCapabilities } from '@nextcloud/capabilities' import { formatFileSize } from '@nextcloud/files' import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' @@ -614,8 +615,10 @@ export default { && this.document.files.every(file => isSignedStatus(file.status)) const signerCompleted = this.isCurrentSignerSigned() if ((isSignedDoc || allFilesSigned || signerCompleted) && (this.isAfterSigned || this.shouldFireAsyncConfetti)) { - const jsConfetti = new JSConfetti() - jsConfetti.addConfetti() + if (getCapabilities()?.libresign?.config?.['show-confetti'] === true) { + const jsConfetti = new JSConfetti() + jsConfetti.addConfetti() + } this.shouldFireAsyncConfetti = false } }, diff --git a/tests/php/Unit/CapabilitiesTest.php b/tests/php/Unit/CapabilitiesTest.php index b2286919c1..9a3f5fd7d8 100644 --- a/tests/php/Unit/CapabilitiesTest.php +++ b/tests/php/Unit/CapabilitiesTest.php @@ -9,11 +9,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Capabilities; use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Service\SignerElementsService; use OCP\App\IAppManager; +use OCP\IAppConfig; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -23,12 +25,14 @@ final class CapabilitiesTest extends \OCA\Libresign\Tests\Unit\TestCase { private SignatureTextService&MockObject $signatureTextService; private IAppManager&MockObject $appManager; private EnvelopeService&MockObject $envelopeService; + private IAppConfig&MockObject $appConfig; public function setUp(): void { $this->signerElementsService = $this->createMock(SignerElementsService::class); $this->signatureTextService = $this->createMock(SignatureTextService::class); $this->appManager = $this->createMock(IAppManager::class); $this->envelopeService = $this->createMock(EnvelopeService::class); + $this->appConfig = $this->createMock(IAppConfig::class); } @@ -38,6 +42,7 @@ private function getClass(): Capabilities { $this->signatureTextService, $this->appManager, $this->envelopeService, + $this->appConfig, ); return $this->capabilities; } @@ -55,4 +60,20 @@ public static function providerSignElementsIsAvailable(): array { [false, false], ]; } + + #[DataProvider('providerShowConfetti')] + public function testShowConfetti(bool $configValue, bool $expected): void { + $this->appConfig->method('getValueBool') + ->with(Application::APP_ID, 'show_confetti_after_signing', true) + ->willReturn($configValue); + $capabilities = $this->getClass()->getCapabilities(); + $this->assertEquals($expected, $capabilities['libresign']['config']['show-confetti']); + } + + public static function providerShowConfetti(): array { + return [ + [true, true], + [false, false], + ]; + } }