refactor(files_reminders): migrate app to Vue 3

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/56694/head
Ferdinand Thiessen 2025-11-25 23:08:47 +07:00
parent e89a8d832c
commit d2ff4214f8
18 changed files with 266 additions and 227 deletions

@ -36,6 +36,7 @@ class LoadAdditionalScriptsListener implements IEventListener {
return;
}
Util::addStyle(Application::APP_ID, 'init');
Util::addInitScript(Application::APP_ID, 'init');
}
}

@ -3,11 +3,118 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { INode } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit as emitEventBus } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { onBeforeMount, onMounted, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import { clearReminder, setReminder } from '../services/reminderService.ts'
import { logger } from '../shared/logger.ts'
import { getInitialCustomDueDate } from '../shared/utils.ts'
const props = defineProps<{
node: INode
}>()
const emit = defineEmits<{
close: [void]
}>()
const hasDueDate = ref(false)
const opened = ref(false)
const isValid = ref(true)
const customDueDate = ref<Date>()
const nowDate = ref(new Date())
onBeforeMount(() => {
const dueDate = props.node.attributes['reminder-due-date']
? new Date(props.node.attributes['reminder-due-date'])
: undefined
hasDueDate.value = Boolean(dueDate)
isValid.value = true
opened.value = true
customDueDate.value = dueDate ?? getInitialCustomDueDate()
nowDate.value = new Date()
})
onMounted(() => {
const input = document.getElementById('set-custom-reminder') as HTMLInputElement
input.focus()
if (!hasDueDate.value) {
input.showPicker()
}
})
/**
* Set the custom reminder
*/
async function setCustom(): Promise<void> {
// Handle input cleared or invalid date
if (!(customDueDate.value instanceof Date) || isNaN(customDueDate.value.getTime())) {
showError(t('files_reminders', 'Please choose a valid date & time'))
return
}
try {
await setReminder(props.node.fileid!, customDueDate.value)
const node = props.node.clone()
node.attributes['reminder-due-date'] = customDueDate.value.toISOString()
emitEventBus('files:node:updated', node)
showSuccess(t('files_reminders', 'Reminder set for "{fileName}"', { fileName: props.node.displayname }))
onClose()
} catch (error) {
logger.error('Failed to set reminder', { error })
showError(t('files_reminders', 'Failed to set reminder'))
}
}
/**
* Clear the reminder
*/
async function clear(): Promise<void> {
try {
await clearReminder(props.node.fileid!)
const node = props.node.clone()
node.attributes['reminder-due-date'] = ''
emitEventBus('files:node:updated', node)
showSuccess(t('files_reminders', 'Reminder cleared for "{fileName}"', { fileName: props.node.displayname }))
onClose()
} catch (error) {
logger.error('Failed to clear reminder', { error })
showError(t('files_reminders', 'Failed to clear reminder'))
}
}
/**
* Close the modal
*/
function onClose(): void {
opened.value = false
emit('close')
}
/**
* Validate the input on change
*/
function onInput(): void {
const input = document.getElementById('set-custom-reminder') as HTMLInputElement
isValid.value = input.checkValidity()
}
</script>
<template>
<NcDialog
v-if="opened"
:name="name"
:out-transition="true"
:name="t('files_reminders', `Set reminder for '{fileName}'`, { fileName: node.displayname })"
out-transition
size="small"
close-on-click-outside
@closing="onClose">
@ -18,13 +125,13 @@
<NcDateTimePickerNative
id="set-custom-reminder"
v-model="customDueDate"
:label="label"
:label="t('files_reminders', 'Reminder at custom date & time')"
:min="nowDate"
:required="true"
type="datetime-local"
@input="onInput" />
<NcNoteCard v-if="isValid" type="info">
<NcNoteCard v-if="isValid && customDueDate" type="info">
{{ t('files_reminders', 'We will remind you of this file') }}
<NcDateTime :timestamp="customDueDate" />
</NcNoteCard>
@ -56,142 +163,6 @@
</NcDialog>
</template>
<script lang="ts">
import type { Node } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import { clearReminder, setReminder } from '../services/reminderService.ts'
import { logger } from '../shared/logger.ts'
import { getDateString, getInitialCustomDueDate } from '../shared/utils.ts'
export default Vue.extend({
name: 'SetCustomReminderModal',
components: {
NcButton,
NcDateTime,
NcDateTimePickerNative,
NcDialog,
NcNoteCard,
},
data() {
return {
node: undefined as Node | undefined,
hasDueDate: false,
opened: false,
isValid: true,
customDueDate: null as null | Date,
nowDate: new Date(),
}
},
computed: {
fileId(): number | undefined {
return this.node?.fileid
},
fileName(): string | undefined {
return this.node?.basename
},
name() {
return this.fileName ? t('files_reminders', 'Set reminder for "{fileName}"', { fileName: this.fileName }) : ''
},
label(): string {
return t('files_reminders', 'Reminder at custom date & time')
},
clearAriaLabel(): string {
return t('files_reminders', 'Clear reminder')
},
},
methods: {
t,
getDateString,
/**
* Open the modal to set a custom reminder
* and reset the state.
*
* @param node The node to set a reminder for
*/
open(node: Node): void {
const dueDate = node.attributes['reminder-due-date'] ? new Date(node.attributes['reminder-due-date']) : null
this.node = node
this.hasDueDate = Boolean(dueDate)
this.isValid = true
this.opened = true
this.customDueDate = dueDate ?? getInitialCustomDueDate()
this.nowDate = new Date()
// Focus the input and show the picker after the animation
setTimeout(() => {
const input = document.getElementById('set-custom-reminder') as HTMLInputElement
input.focus()
if (!this.hasDueDate) {
input.showPicker()
}
}, 300)
},
async setCustom(): Promise<void> {
// Handle input cleared or invalid date
if (!(this.customDueDate instanceof Date) || isNaN(this.customDueDate)) {
showError(t('files_reminders', 'Please choose a valid date & time'))
return
}
try {
await setReminder(this.fileId, this.customDueDate)
Vue.set(this.node.attributes, 'reminder-due-date', this.customDueDate.toISOString())
emit('files:node:updated', this.node)
showSuccess(t('files_reminders', 'Reminder set for "{fileName}"', { fileName: this.fileName }))
this.onClose()
} catch (error) {
logger.error('Failed to set reminder', { error })
showError(t('files_reminders', 'Failed to set reminder'))
}
},
async clear(): Promise<void> {
try {
await clearReminder(this.fileId)
Vue.set(this.node.attributes, 'reminder-due-date', '')
emit('files:node:updated', this.node)
showSuccess(t('files_reminders', 'Reminder cleared for "{fileName}"', { fileName: this.fileName }))
this.onClose()
} catch (error) {
logger.error('Failed to clear reminder', { error })
showError(t('files_reminders', 'Failed to clear reminder'))
}
},
onClose(): void {
this.opened = false
this.$emit('close')
},
onInput(): void {
const input = document.getElementById('set-custom-reminder') as HTMLInputElement
this.isValid = input.checkValidity()
},
},
})
</script>
<style lang="scss" scoped>
.custom-reminder-modal {
margin: 0 12px;

@ -0,0 +1,20 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { registerFileAction } from '@nextcloud/files'
import { registerDavProperty } from '@nextcloud/files/dav'
import { action as clearAction } from './files_actions/clearReminderAction.ts'
import { action as statusAction } from './files_actions/reminderStatusAction.ts'
import { action as customAction } from './files_actions/setReminderCustomAction.ts'
import { action as menuAction } from './files_actions/setReminderMenuAction.ts'
import { actions as suggestionActions } from './files_actions/setReminderSuggestionActions.ts'
registerDavProperty('nc:reminder-due-date', { nc: 'http://nextcloud.org/ns' })
registerFileAction(statusAction)
registerFileAction(clearAction)
registerFileAction(menuAction)
registerFileAction(customAction)
suggestionActions.forEach((action) => registerFileAction(action))

@ -0,0 +1,40 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import { Folder } from '@nextcloud/files'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { action } from './clearReminderAction.ts'
describe('clearReminderAction', () => {
const folder = new Folder({
owner: 'user',
source: 'https://example.com/remote.php/dav/files/user/folder',
attributes: {
'reminder-due-date': '2024-12-25T10:00:00Z',
},
})
beforeEach(() => vi.resetAllMocks())
it('should be enabled for one node with due date', () => {
expect(action.enabled!([folder], {} as unknown as View)).toBe(true)
})
it('should be disabled with more than one node', () => {
expect(action.enabled!([folder, folder], {} as unknown as View)).toBe(false)
})
it('should be disabled if no due date', () => {
const node = folder.clone()
delete node.attributes['reminder-due-date']
expect(action.enabled!([node], {} as unknown as View)).toBe(false)
})
it('should have title based on due date', () => {
expect(action.title!([folder], {} as unknown as View)).toMatchInlineSnapshot('"Clear reminder Wednesday, December 25, 2024 at 10:00 AM"')
})
})

@ -3,15 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode } from '@nextcloud/files'
import AlarmOffSvg from '@mdi/svg/svg/alarm-off.svg?raw'
import { emit } from '@nextcloud/event-bus'
import {
type Node,
FileAction,
} from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
import { FileAction } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { clearReminder } from '../services/reminderService.ts'
import { getVerboseDateString } from '../shared/utils.ts'
@ -20,7 +17,7 @@ export const action = new FileAction({
displayName: () => t('files_reminders', 'Clear reminder'),
title: (nodes: Node[]) => {
title: (nodes: INode[]) => {
const node = nodes.at(0)!
const dueDate = new Date(node.attributes['reminder-due-date'])
return `${t('files_reminders', 'Clear reminder')} ${getVerboseDateString(dueDate)}`
@ -28,7 +25,7 @@ export const action = new FileAction({
iconSvgInline: () => AlarmOffSvg,
enabled: (nodes: Node[]) => {
enabled: (nodes: INode[]) => {
// Only allow on a single node
if (nodes.length !== 1) {
return false
@ -38,11 +35,11 @@ export const action = new FileAction({
return Boolean(dueDate)
},
async exec(node: Node) {
async exec(node: INode) {
if (node.fileid) {
try {
await clearReminder(node.fileid)
Vue.set(node.attributes, 'reminder-due-date', '')
node.attributes['reminder-due-date'] = ''
emit('files:node:updated', node)
return true
} catch {

@ -0,0 +1,40 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import { Folder } from '@nextcloud/files'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { action } from './reminderStatusAction.ts'
describe('reminderStatusAction', () => {
const folder = new Folder({
owner: 'user',
source: 'https://example.com/remote.php/dav/files/user/folder',
attributes: {
'reminder-due-date': '2024-12-25T10:00:00Z',
},
})
beforeEach(() => vi.resetAllMocks())
it('should be enabled for one node with due date', () => {
expect(action.enabled!([folder], {} as unknown as View)).toBe(true)
})
it('should be disabled with more than one node', () => {
expect(action.enabled!([folder, folder], {} as unknown as View)).toBe(false)
})
it('should be disabled if no due date', () => {
const node = folder.clone()
delete node.attributes['reminder-due-date']
expect(action.enabled!([node], {} as unknown as View)).toBe(false)
})
it('should have title based on due date', () => {
expect(action.title!([folder], {} as unknown as View)).toMatchInlineSnapshot('"Reminder set Wednesday, December 25, 2024 at 10:00 AM"')
})
})

@ -3,13 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import AlarmSvg from '@mdi/svg/svg/alarm.svg?raw'
import {
type Node,
import type { INode } from '@nextcloud/files'
FileAction,
} from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import AlarmSvg from '@mdi/svg/svg/alarm.svg?raw'
import { FileAction } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { pickCustomDate } from '../services/customPicker.ts'
import { getVerboseDateString } from '../shared/utils.ts'
@ -20,7 +18,7 @@ export const action = new FileAction({
displayName: () => '',
title: (nodes: Node[]) => {
title: (nodes: INode[]) => {
const node = nodes.at(0)!
const dueDate = new Date(node.attributes['reminder-due-date'])
return `${t('files_reminders', 'Reminder set')} ${getVerboseDateString(dueDate)}`
@ -28,7 +26,7 @@ export const action = new FileAction({
iconSvgInline: () => AlarmSvg,
enabled: (nodes: Node[]) => {
enabled: (nodes: INode[]) => {
// Only allow on a single node
if (nodes.length !== 1) {
return false
@ -38,8 +36,8 @@ export const action = new FileAction({
return Boolean(dueDate)
},
async exec(node: Node) {
pickCustomDate(node)
async exec(node: INode) {
await pickCustomDate(node)
return null
},

@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import type { INode, View } from '@nextcloud/files'
import CalendarClockSvg from '@mdi/svg/svg/calendar-clock.svg?raw'
import { FileAction } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { t } from '@nextcloud/l10n'
import { pickCustomDate } from '../services/customPicker.ts'
import { SET_REMINDER_MENU_ID } from './setReminderMenuAction.ts'
@ -17,11 +17,11 @@ export const action = new FileAction({
title: () => t('files_reminders', 'Reminder at custom date & time'),
iconSvgInline: () => CalendarClockSvg,
enabled: (nodes: Node[], view: View) => {
enabled: (nodes: INode[], view: View) => {
if (view.id === 'trashbin') {
return false
}
// Only allow on a single node
// Only allow on a single INode
if (nodes.length !== 1) {
return false
}
@ -32,7 +32,7 @@ export const action = new FileAction({
parent: SET_REMINDER_MENU_ID,
async exec(file: Node) {
async exec(file: INode) {
pickCustomDate(file)
return null
},

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import type { INode, View } from '@nextcloud/files'
import AlarmSvg from '@mdi/svg/svg/alarm.svg?raw'
import { FileAction } from '@nextcloud/files'
@ -16,11 +16,11 @@ export const action = new FileAction({
displayName: () => t('files_reminders', 'Set reminder'),
iconSvgInline: () => AlarmSvg,
enabled: (nodes: Node[], view: View) => {
enabled: (nodes: INode[], view: View) => {
if (view.id === 'trashbin') {
return false
}
// Only allow on a single node
// Only allow on a single INode
if (nodes.length !== 1) {
return false
}

@ -3,13 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import type { INode, View } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { FileAction } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import Vue from 'vue'
import { setReminder } from '../services/reminderService.ts'
import { logger } from '../shared/logger.ts'
import { DateTimePreset, getDateString, getDateTime, getVerboseDateString } from '../shared/utils.ts'
@ -73,11 +72,11 @@ function generateFileAction(option: ReminderOption): FileAction | null {
// Empty svg to hide the icon
iconSvgInline: () => '<svg></svg>',
enabled: (nodes: Node[], view: View) => {
enabled: (nodes: INode[], view: View) => {
if (view.id === 'trashbin') {
return false
}
// Only allow on a single node
// Only allow on a single INode
if (nodes.length !== 1) {
return false
}
@ -88,7 +87,7 @@ function generateFileAction(option: ReminderOption): FileAction | null {
parent: SET_REMINDER_MENU_ID,
async exec(node: Node) {
async exec(node: INode) {
// Can't really happen, but just in case™
if (!node.fileid) {
logger.error('Failed to set reminder, missing file id')
@ -100,7 +99,7 @@ function generateFileAction(option: ReminderOption): FileAction | null {
try {
const dateTime = getDateTime(option.dateTimePreset)!
await setReminder(node.fileid, dateTime)
Vue.set(node.attributes, 'reminder-due-date', dateTime.toISOString())
node.attributes['reminder-due-date'] = dateTime.toISOString()
emit('files:node:updated', node)
showSuccess(t('files_reminders', 'Reminder set for "{fileName}"', { fileName: node.basename }))
} catch (error) {

@ -1,19 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { registerDavProperty, registerFileAction } from '@nextcloud/files'
import { action as clearAction } from './actions/clearReminderAction.ts'
import { action as statusAction } from './actions/reminderStatusAction.ts'
import { action as customAction } from './actions/setReminderCustomAction.ts'
import { action as menuAction } from './actions/setReminderMenuAction.ts'
import { actions as suggestionActions } from './actions/setReminderSuggestionActions.ts'
registerDavProperty('nc:reminder-due-date', { nc: 'http://nextcloud.org/ns' })
registerFileAction(statusAction)
registerFileAction(clearAction)
registerFileAction(menuAction)
registerFileAction(customAction)
suggestionActions.forEach((action) => registerFileAction(action))

@ -3,31 +3,16 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import type { INode } from '@nextcloud/files'
import Vue from 'vue'
import { spawnDialog } from '@nextcloud/vue'
import SetCustomReminderModal from '../components/SetCustomReminderModal.vue'
const View = Vue.extend(SetCustomReminderModal)
const mount = document.createElement('div')
mount.id = 'set-custom-reminder-modal'
document.body.appendChild(mount)
// Create a new Vue instance and mount it to our modal container
const CustomReminderModal = new View({
name: 'SetCustomReminderModal',
el: mount,
})
/**
*
* @param node
* @param node - The file or folder node to set the custom reminder for
*/
export function pickCustomDate(node: Node): Promise<void> {
CustomReminderModal.open(node)
// Wait for the modal to close
return new Promise((resolve) => {
CustomReminderModal.$once('close', resolve)
export async function pickCustomDate(node: INode): Promise<void> {
await spawnDialog(SetCustomReminderModal, {
node,
})
}

@ -11,8 +11,9 @@ interface Reminder {
}
/**
* Get the reminder for a specific file
*
* @param fileId
* @param fileId - The file id to get the reminder for
*/
export async function getReminder(fileId: number): Promise<Reminder> {
const url = generateOcsUrl('/apps/files_reminders/api/v1/{fileId}', { fileId })
@ -25,9 +26,10 @@ export async function getReminder(fileId: number): Promise<Reminder> {
}
/**
* Set a reminder for a specific file
*
* @param fileId
* @param dueDate
* @param fileId - The file id to set the reminder for
* @param dueDate - The due date for the reminder
*/
export async function setReminder(fileId: number, dueDate: Date): Promise<[]> {
const url = generateOcsUrl('/apps/files_reminders/api/v1/{fileId}', { fileId })
@ -40,8 +42,9 @@ export async function setReminder(fileId: number, dueDate: Date): Promise<[]> {
}
/**
* Clear the reminder for a specific file
*
* @param fileId
* @param fileId - The file id to clear the reminder for
*/
export async function clearReminder(fileId: number): Promise<[]> {
const url = generateOcsUrl('/apps/files_reminders/api/v1/{fileId}', { fileId })

@ -44,9 +44,6 @@ module.exports = {
init: path.join(__dirname, 'apps/files_external/src', 'init.ts'),
settings: path.join(__dirname, 'apps/files_external/src', 'settings.js'),
},
files_reminders: {
init: path.join(__dirname, 'apps/files_reminders/src', 'init.ts'),
},
files_sharing: {
additionalScripts: path.join(__dirname, 'apps/files_sharing/src', 'additionalScripts.js'),
collaboration: path.join(__dirname, 'apps/files_sharing/src', 'collaborationresourceshandler.js'),

@ -7,17 +7,20 @@ import { createAppConfig } from '@nextcloud/vite-config'
import { resolve } from 'node:path'
const modules = {
dav: {
'settings-admin-caldav': resolve(import.meta.dirname, 'apps/dav/src', 'settings-admin.ts'),
'settings-admin-example-content': resolve(import.meta.dirname, 'apps/dav/src', 'settings-admin-example-content.ts'),
'settings-personal-availability': resolve(import.meta.dirname, 'apps/dav/src', 'settings-personal-availability.ts'),
},
files_reminders: {
init: resolve(import.meta.dirname, 'apps/files_reminders/src', 'files-init.ts'),
},
files_trashbin: {
init: resolve(import.meta.dirname, 'apps/files_trashbin/src', 'files-init.ts'),
},
files_versions: {
'sidebar-tab': resolve(import.meta.dirname, 'apps/files_versions/src', 'sidebar_tab.ts'),
},
dav: {
'settings-admin-caldav': resolve(import.meta.dirname, 'apps/dav/src', 'settings-admin.ts'),
'settings-admin-example-content': resolve(import.meta.dirname, 'apps/dav/src', 'settings-admin-example-content.ts'),
'settings-personal-availability': resolve(import.meta.dirname, 'apps/dav/src', 'settings-personal-availability.ts'),
},
sharebymail: {
'admin-settings': resolve(import.meta.dirname, 'apps/sharebymail/src', 'settings-admin.ts'),
},

@ -38,6 +38,10 @@ export default defineConfig({
},
test: {
include: ['apps/**/*.{test,spec}.?(c|m)[jt]s?(x)'],
env: {
LANG: 'en_US',
TZ: 'UTC',
},
environment: 'jsdom',
environmentOptions: {
jsdom: {