Merge pull request #43589 from nextcloud/feat/reminder-status

feat(files_reminders): Add reminder status indicator
This commit is contained in:
Pytal 2024-03-09 00:21:09 -08:00 committed by GitHub
commit 6cf51f9448
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 269 additions and 111 deletions

View file

@ -77,7 +77,7 @@
:key="action.id"
:class="`files-list__row-action-${action.id}`"
class="files-list__row-action--submenu"
:close-after-click="false /* never close submenu, just go back */"
close-after-click
:data-cy-files-list-row-action="action.id"
:title="action.title?.([source], currentView)"
@click="onActionClick(action)">

View file

@ -13,10 +13,6 @@ Set file reminders.
<author>Christopher Ng</author>
<namespace>FilesReminders</namespace>
<types>
<dav />
</types>
<category>files</category>
<bugs>https://github.com/nextcloud/server/issues</bugs>
@ -33,10 +29,4 @@ Set file reminders.
<commands>
<command>OCA\FilesReminders\Command\ListCommand</command>
</commands>
<sabre>
<plugins>
<plugin>OCA\FilesReminders\Dav\PropFindPlugin</plugin>
</plugins>
</sabre>
</info>

View file

@ -19,6 +19,7 @@ return array(
'OCA\\FilesReminders\\Exception\\UserNotFoundException' => $baseDir . '/../lib/Exception/UserNotFoundException.php',
'OCA\\FilesReminders\\Listener\\LoadAdditionalScriptsListener' => $baseDir . '/../lib/Listener/LoadAdditionalScriptsListener.php',
'OCA\\FilesReminders\\Listener\\NodeDeletedListener' => $baseDir . '/../lib/Listener/NodeDeletedListener.php',
'OCA\\FilesReminders\\Listener\\SabrePluginAddListener' => $baseDir . '/../lib/Listener/SabrePluginAddListener.php',
'OCA\\FilesReminders\\Listener\\UserDeletedListener' => $baseDir . '/../lib/Listener/UserDeletedListener.php',
'OCA\\FilesReminders\\Migration\\Version10000Date20230725162149' => $baseDir . '/../lib/Migration/Version10000Date20230725162149.php',
'OCA\\FilesReminders\\Model\\RichReminder' => $baseDir . '/../lib/Model/RichReminder.php',

View file

@ -34,6 +34,7 @@ class ComposerStaticInitFilesReminders
'OCA\\FilesReminders\\Exception\\UserNotFoundException' => __DIR__ . '/..' . '/../lib/Exception/UserNotFoundException.php',
'OCA\\FilesReminders\\Listener\\LoadAdditionalScriptsListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalScriptsListener.php',
'OCA\\FilesReminders\\Listener\\NodeDeletedListener' => __DIR__ . '/..' . '/../lib/Listener/NodeDeletedListener.php',
'OCA\\FilesReminders\\Listener\\SabrePluginAddListener' => __DIR__ . '/..' . '/../lib/Listener/SabrePluginAddListener.php',
'OCA\\FilesReminders\\Listener\\UserDeletedListener' => __DIR__ . '/..' . '/../lib/Listener/UserDeletedListener.php',
'OCA\\FilesReminders\\Migration\\Version10000Date20230725162149' => __DIR__ . '/..' . '/../lib/Migration/Version10000Date20230725162149.php',
'OCA\\FilesReminders\\Model\\RichReminder' => __DIR__ . '/..' . '/../lib/Model/RichReminder.php',

View file

@ -26,9 +26,11 @@ declare(strict_types=1);
namespace OCA\FilesReminders\AppInfo;
use OCA\DAV\Events\SabrePluginAddEvent;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\FilesReminders\Listener\LoadAdditionalScriptsListener;
use OCA\FilesReminders\Listener\NodeDeletedListener;
use OCA\FilesReminders\Listener\SabrePluginAddListener;
use OCA\FilesReminders\Listener\UserDeletedListener;
use OCA\FilesReminders\Notification\Notifier;
use OCP\AppFramework\App;
@ -51,6 +53,8 @@ class Application extends App implements IBootstrap {
public function register(IRegistrationContext $context): void {
$context->registerNotifierService(Notifier::class);
$context->registerEventListener(SabrePluginAddEvent::class, SabrePluginAddListener::class);
$context->registerEventListener(NodeDeletedEvent::class, NodeDeletedListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/**
* @copyright 2024 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\FilesReminders\Listener;
use OCA\DAV\Events\SabrePluginAddEvent;
use OCA\FilesReminders\Dav\PropFindPlugin;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use Psr\Container\ContainerInterface;
/** @template-implements IEventListener<SabrePluginAddEvent> */
class SabrePluginAddListener implements IEventListener {
public function __construct(
private ContainerInterface $container,
) {
}
public function handle(Event $event): void {
if (!($event instanceof SabrePluginAddEvent)) {
return;
}
$server = $event->getServer();
$plugin = $this->container->get(PropFindPlugin::class);
$server->addPlugin($plugin);
}
}

View file

@ -33,12 +33,12 @@ import { getVerboseDateString } from '../shared/utils.ts'
export const action = new FileAction({
id: 'clear-reminder',
displayName: () => t('files', 'Clear reminder'),
displayName: () => t('files_reminders', 'Clear reminder'),
title: (nodes: Node[]) => {
const node = nodes.at(0)!
const dueDate = new Date(node.attributes['reminder-due-date'])
return `${t('files', 'Clear reminder')} ${getVerboseDateString(dueDate)}`
return `${t('files_reminders', 'Clear reminder')} ${getVerboseDateString(dueDate)}`
},
iconSvgInline: () => AlarmOffSvg,

View file

@ -0,0 +1,62 @@
/**
* @copyright 2024 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { FileAction, type Node } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import AlarmSvg from '@mdi/svg/svg/alarm.svg?raw'
import { pickCustomDate } from '../services/customPicker.ts'
import { getVerboseDateString } from '../shared/utils.ts'
export const action = new FileAction({
id: 'reminder-status',
inline: () => true,
displayName: () => '',
title: (nodes: Node[]) => {
const node = nodes.at(0)!
const dueDate = new Date(node.attributes['reminder-due-date'])
return `${t('files_reminders', 'Reminder set')} ${getVerboseDateString(dueDate)}`
},
iconSvgInline: () => AlarmSvg,
enabled: (nodes: Node[]) => {
// Only allow on a single node
if (nodes.length !== 1) {
return false
}
const node = nodes.at(0)!
const dueDate = node.attributes['reminder-due-date']
return Boolean(dueDate)
},
async exec(node: Node) {
pickCustomDate(node)
return null
},
order: -15,
})

View file

@ -28,7 +28,7 @@ import { pickCustomDate } from '../services/customPicker'
export const action = new FileAction({
id: 'set-reminder-custom',
displayName: () => t('files', 'Set custom reminder'),
displayName: () => t('files_reminders', 'Set custom reminder'),
title: () => t('files_reminders', 'Set reminder at custom date & time'),
iconSvgInline: () => CalendarClockSvg,

View file

@ -27,7 +27,7 @@ export const SET_REMINDER_MENU_ID = 'set-reminder-menu'
export const action = new FileAction({
id: SET_REMINDER_MENU_ID,
displayName: () => t('files', 'Set reminder'),
displayName: () => t('files_reminders', 'Set reminder'),
iconSvgInline: () => AlarmSvg,
enabled: () => true,

View file

@ -21,15 +21,15 @@
-->
<template>
<NcModal v-if="opened"
<NcDialog v-if="opened"
:name="name"
:out-transition="true"
size="small"
@close="onClose">
<form class="custom-reminder-modal" @submit.prevent="setCustom">
<h2 class="custom-reminder-modal__title">
{{ title }}
</h2>
close-on-click-outside
@closing="onClose">
<form id="set-custom-reminder-form"
class="custom-reminder-modal"
@submit.prevent="setCustom">
<NcDateTimePickerNative id="set-custom-reminder"
v-model="customDueDate"
:label="label"
@ -46,21 +46,27 @@
<NcNoteCard v-else type="error">
{{ t('files_reminders', 'Please choose a valid date & time') }}
</NcNoteCard>
<!-- Buttons -->
<div class="custom-reminder-modal__buttons">
<!-- Cancel pick -->
<NcButton @click="onClose">
{{ t('files_reminders', 'Cancel') }}
</NcButton>
<!-- Set reminder -->
<NcButton :disabled="!isValid" native-type="submit" type="primary">
{{ t('files_reminders', 'Set reminder') }}
</NcButton>
</div>
</form>
</NcModal>
<template #actions>
<!-- Cancel pick -->
<NcButton type="tertiary" @click="onClose">
{{ t('files_reminders', 'Cancel') }}
</NcButton>
<!-- Clear reminder -->
<NcButton v-if="hasDueDate" @click="clear">
{{ t('files_reminders', 'Clear reminder') }}
</NcButton>
<!-- Set reminder -->
<NcButton :disabled="!isValid"
type="primary"
form="set-custom-reminder-form"
native-type="submit">
{{ t('files_reminders', 'Set reminder') }}
</NcButton>
</template>
</NcDialog>
</template>
<script lang="ts">
@ -73,12 +79,12 @@ import { translate as t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import { getDateString, getInitialCustomDueDate } from '../shared/utils.ts'
import { logger } from '../shared/logger.ts'
import { setReminder } from '../services/reminderService.ts'
import { clearReminder, setReminder } from '../services/reminderService.ts'
export default Vue.extend({
name: 'SetCustomReminderModal',
@ -87,17 +93,18 @@ export default Vue.extend({
NcButton,
NcDateTime,
NcDateTimePickerNative,
NcModal,
NcDialog,
NcNoteCard,
},
data() {
return {
node: undefined as Node | undefined,
hasDueDate: false,
opened: false,
isValid: true,
customDueDate: getInitialCustomDueDate() as '' | Date,
customDueDate: null as null | Date,
nowDate: new Date(),
}
},
@ -111,7 +118,7 @@ export default Vue.extend({
return this.node.basename
},
title() {
name() {
return t('files_reminders', 'Set reminder for "{fileName}"', { fileName: this.fileName })
},
@ -133,18 +140,23 @@ export default Vue.extend({
* and reset the state.
* @param node The node to set a reminder for
*/
async open(node: Node): Promise<void> {
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 = getInitialCustomDueDate()
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()
input.showPicker()
if (!this.hasDueDate) {
input.showPicker()
}
}, 300)
},
@ -167,6 +179,19 @@ export default Vue.extend({
}
},
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')
@ -182,21 +207,6 @@ export default Vue.extend({
<style lang="scss" scoped>
.custom-reminder-modal {
margin: 30px;
&__title {
font-size: 16px;
line-height: 2em;
}
&__buttons {
display: flex;
justify-content: flex-end;
margin-top: 30px;
button {
margin-left: 10px;
}
}
margin: 0 12px;
}
</style>

View file

@ -21,13 +21,15 @@
*/
import { registerDavProperty, registerFileAction } from '@nextcloud/files'
import { action as menuAction } from './actions/setReminderMenuAction'
import { action as statusAction } from './actions/reminderStatusAction'
import { action as clearAction } from './actions/clearReminderAction'
import { action as menuAction } from './actions/setReminderMenuAction'
import { actions as suggestionActions } from './actions/setReminderSuggestionActions'
import { action as customAction } from './actions/setReminderCustomAction'
registerDavProperty('nc:reminder-due-date', { nc: 'http://nextcloud.org/ns' })
registerFileAction(statusAction)
registerFileAction(clearAction)
registerFileAction(menuAction)
registerFileAction(customAction)

View file

@ -36,11 +36,11 @@ const CustomReminderModal = new View({
el: mount,
})
export const pickCustomDate = async (node: Node): Promise<void> => {
export const pickCustomDate = (node: Node): Promise<void> => {
CustomReminderModal.open(node)
// Wait for the modal to close
return new Promise((resolve) => {
CustomReminderModal.$on('close', resolve)
CustomReminderModal.$once('close', resolve)
})
}

View file

@ -20,7 +20,6 @@
*
*/
import moment from '@nextcloud/moment'
import { getCanonicalLocale } from '@nextcloud/l10n'
export enum DateTimePreset {
@ -30,58 +29,82 @@ export enum DateTimePreset {
NextWeek = 'next-week',
}
const getFirstWorkdayOfWeek = () => {
const now = new Date()
now.setHours(0, 0, 0, 0)
now.setDate(now.getDate() - now.getDay() + 1)
return new Date(now)
}
const getWeek = (date: Date) => {
const dateClone = new Date(date)
dateClone.setHours(0, 0, 0, 0)
const firstDayOfYear = new Date(date.getFullYear(), 0, 1, 0, 0, 0, 0)
const daysFromFirstDay = (date.getTime() - firstDayOfYear.getTime()) / 86400000
return Math.ceil((daysFromFirstDay + firstDayOfYear.getDay() + 1) / 7)
}
const isSameWeek = (a: Date, b: Date) => {
return getWeek(a) === getWeek(b)
&& a.getFullYear() === b.getFullYear()
}
const isSameDate = (a: Date, b: Date) => {
return a.getDate() === b.getDate()
&& a.getMonth() === b.getMonth()
&& a.getFullYear() === b.getFullYear()
}
export const getDateTime = (dateTime: DateTimePreset): null | Date => {
const matchPreset: Record<DateTimePreset, () => null | Date> = {
[DateTimePreset.LaterToday]: () => {
const now = moment()
const evening = moment()
.startOf('day')
.add(18, 'hour')
const cutoff = evening
.clone()
.subtract(1, 'hour')
if (now.isSameOrAfter(cutoff)) {
const now = new Date()
const evening = new Date()
evening.setHours(18, 0, 0, 0)
const cutoff = new Date()
cutoff.setHours(17, 0, 0, 0)
if (now >= cutoff) {
return null
}
return evening.toDate()
return evening
},
[DateTimePreset.Tomorrow]: () => {
const day = moment()
.add(1, 'day')
.startOf('day')
.add(8, 'hour')
return day.toDate()
const now = new Date()
const day = new Date()
day.setDate(now.getDate() + 1)
day.setHours(8, 0, 0, 0)
return day
},
[DateTimePreset.ThisWeekend]: () => {
const today = moment()
const today = new Date()
if (
[
5, // Friday
6, // Saturday
7, // Sunday
].includes(today.isoWeekday())
0, // Sunday
].includes(today.getDay())
) {
return null
}
const saturday = moment()
.startOf('isoWeek')
.add(5, 'day')
.add(8, 'hour')
return saturday.toDate()
const saturday = new Date()
const firstWorkdayOfWeek = getFirstWorkdayOfWeek()
saturday.setDate(firstWorkdayOfWeek.getDate() + 5)
saturday.setHours(8, 0, 0, 0)
return saturday
},
[DateTimePreset.NextWeek]: () => {
const today = moment()
if (today.isoWeekday() === 7) { // Sunday
const today = new Date()
if (today.getDay() === 0) { // Sunday
return null
}
const workday = moment()
.startOf('isoWeek')
.add(1, 'week')
.add(8, 'hour')
return workday.toDate()
const workday = new Date()
const firstWorkdayOfWeek = getFirstWorkdayOfWeek()
workday.setDate(firstWorkdayOfWeek.getDate() + 7)
workday.setHours(8, 0, 0, 0)
return workday
},
}
@ -89,11 +112,10 @@ export const getDateTime = (dateTime: DateTimePreset): null | Date => {
}
export const getInitialCustomDueDate = (): Date => {
const hour = moment().get('hour')
const dueDate = moment()
.startOf('day')
.add(hour + 2, 'hour')
return dueDate.toDate()
const now = new Date()
const dueDate = new Date()
dueDate.setHours(now.getHours() + 2, 0, 0, 0)
return dueDate
}
export const getDateString = (dueDate: Date): string => {
@ -102,17 +124,16 @@ export const getDateString = (dueDate: Date): string => {
minute: '2-digit',
}
const dueDateMoment = moment(dueDate)
const today = moment()
const today = new Date()
if (!dueDateMoment.isSame(today, 'date')) {
if (!isSameDate(dueDate, today)) {
formatOptions = {
...formatOptions,
weekday: 'short',
}
}
if (!dueDateMoment.isSame(today, 'week')) {
if (!isSameWeek(dueDate, today)) {
formatOptions = {
...formatOptions,
month: 'short',
@ -120,6 +141,13 @@ export const getDateString = (dueDate: Date): string => {
}
}
if (dueDate.getFullYear() !== today.getFullYear()) {
formatOptions = {
...formatOptions,
year: 'numeric',
}
}
return dueDate.toLocaleString(
getCanonicalLocale(),
formatOptions,
@ -127,12 +155,21 @@ export const getDateString = (dueDate: Date): string => {
}
export const getVerboseDateString = (dueDate: Date): string => {
const formatOptions: Intl.DateTimeFormatOptions = {
let formatOptions: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
weekday: 'long',
hour: 'numeric',
minute: '2-digit',
month: 'long',
day: 'numeric',
}
const today = new Date()
if (dueDate.getFullYear() !== today.getFullYear()) {
formatOptions = {
...formatOptions,
year: 'numeric',
}
}
return dueDate.toLocaleString(

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long