diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 678073244c..a30a509101 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -10,8 +10,10 @@ use OCA\Calendar\Dashboard\CalendarWidget; use OCA\Calendar\Events\BeforeAppointmentBookedEvent; +use OCA\Calendar\Listener\AppMenuActionListener; use OCA\Calendar\Listener\AppointmentBookedListener; use OCA\Calendar\Listener\CalendarReferenceListener; +use OCA\Calendar\Listener\EditorInitialStateListener; use OCA\Calendar\Listener\NotifyPushListener; use OCA\Calendar\Listener\UserDeletedListener; use OCA\Calendar\Notification\Notifier; @@ -22,11 +24,17 @@ use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\Calendar\Events\CalendarObjectCreatedEvent; use OCP\Calendar\Events\CalendarObjectDeletedEvent; use OCP\Calendar\Events\CalendarObjectUpdatedEvent; use OCP\Collaboration\Reference\RenderReferenceEvent; +use OCP\INavigationManager; +use OCP\IURLGenerator; use OCP\IUserSession; +use OCP\L10N\IFactory; +use OCP\Navigation\Events\LoadAdditionalEntriesEvent; +use OCP\ServerVersion; use OCP\User\Events\UserDeletedEvent; use OCP\Util; use Psr\Container\ContainerInterface; @@ -35,6 +43,12 @@ class Application extends App implements IBootstrap { /** @var string */ public const APP_ID = 'calendar'; + /** + * Actions in the app menu, and with it the new event dialog, + * are only supported since Nextcloud 35. + */ + private const APP_MENU_ACTION_VERSION = 35; + /** * @param array $params */ @@ -58,6 +72,12 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(BeforeAppointmentBookedEvent::class, AppointmentBookedListener::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerEventListener(RenderReferenceEvent::class, CalendarReferenceListener::class); + if ($this->hasAppMenuActions()) { + // The editor of the new event dialog can be opened on any page + $context->registerEventListener(BeforeTemplateRenderedEvent::class, EditorInitialStateListener::class); + // The app navigation action + $context->registerEventListener(LoadAdditionalEntriesEvent::class, AppMenuActionListener::class); + } $context->registerEventListener(CalendarObjectCreatedEvent::class, NotifyPushListener::class); $context->registerEventListener(CalendarObjectUpdatedEvent::class, NotifyPushListener::class); @@ -89,4 +109,11 @@ private function addContactsMenuScript(ContainerInterface $container): void { Util::addScript(self::APP_ID, 'calendar-contacts-menu'); Util::addStyle(self::APP_ID, 'calendar-contacts-menu'); } + + /** + * Whether the server supports actions in the app menu. + */ + private function hasAppMenuActions(): bool { + return (new ServerVersion())->getMajorVersion() >= self::APP_MENU_ACTION_VERSION; + } } diff --git a/lib/Listener/AppMenuActionListener.php b/lib/Listener/AppMenuActionListener.php new file mode 100644 index 0000000000..d8d26e0db5 --- /dev/null +++ b/lib/Listener/AppMenuActionListener.php @@ -0,0 +1,58 @@ + + */ +class AppMenuActionListener implements IEventListener { + public function __construct( + private IL10N $l10n, + private INavigationManager $navigationManager, + private IURLGenerator $urlGenerator, + private IUserSession $userSession, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof LoadAdditionalEntriesEvent) { + return; + } + + // Events can only be created for a user + if (!$this->userSession->isLoggedIn()) { + return; + } + + $this->navigationManager->add([ + 'id' => 'calendar:new-event', + 'order' => 4, + 'icon' => $this->urlGenerator->imagePath(Application::APP_ID, 'calendar.svg'), + 'name' => $this->l10n->t('Event'), // TRANSLATORS: This is the label of the action in the app menu to create a new calendar event + 'type' => INavigationManager::TYPE_ACTION, + ]); + + // Handles clicks on the action and spawns the editor + Util::addScript(Application::APP_ID, 'calendar-appMenu'); + } +} diff --git a/lib/Listener/EditorInitialStateListener.php b/lib/Listener/EditorInitialStateListener.php new file mode 100644 index 0000000000..0def964e21 --- /dev/null +++ b/lib/Listener/EditorInitialStateListener.php @@ -0,0 +1,43 @@ + + */ +class EditorInitialStateListener implements IEventListener { + public function __construct( + private IUserSession $userSession, + private CalendarInitialStateService $calendarInitialStateService, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof BeforeTemplateRenderedEvent) { + return; + } + + if (!$this->userSession->isLoggedIn()) { + return; + } + + $this->calendarInitialStateService->runForEditor(); + } +} diff --git a/lib/Service/CalendarInitialStateService.php b/lib/Service/CalendarInitialStateService.php index 20516b4f2b..85c3674ab7 100644 --- a/lib/Service/CalendarInitialStateService.php +++ b/lib/Service/CalendarInitialStateService.php @@ -23,6 +23,8 @@ class CalendarInitialStateService { + private bool $editorStateProvided = false; + public function __construct( private string $appName, private IInitialState $initialStateService, @@ -41,50 +43,32 @@ public function __construct( } public function run(): void { + $this->runForEditor(); + $defaultEventLimit = $this->config->getAppValue($this->appName, 'eventLimit', 'yes'); $defaultInitialView = $this->config->getAppValue($this->appName, 'currentView', 'dayGridMonth'); $defaultShowWeekends = $this->config->getAppValue($this->appName, 'showWeekends', 'yes'); $defaultWeekNumbers = $this->config->getAppValue($this->appName, 'showWeekNr', 'no'); $defaultSkipPopover = $this->config->getAppValue($this->appName, 'skipPopover', 'no'); - $defaultTimezone = $this->config->getAppValue($this->appName, 'timezone', 'automatic'); $defaultSlotDuration = $this->config->getAppValue($this->appName, 'slotDuration', '00:30:00'); - $defaultDefaultReminder = $this->config->getAppValue($this->appName, 'defaultReminder', 'none'); $defaultShowTasks = $this->config->getAppValue($this->appName, 'showTasks', 'yes'); $defaultTasksSidebar = $this->config->getAppValue($this->appName, 'tasksSidebar', 'yes'); - $appVersion = $this->config->getAppValue($this->appName, 'installed_version', ''); $eventLimit = $this->config->getUserValue($this->userId, $this->appName, 'eventLimit', $defaultEventLimit) === 'yes'; $firstRun = $this->config->getUserValue($this->userId, $this->appName, 'firstRun', 'yes') === 'yes'; $initialView = $this->getView($this->config->getUserValue($this->userId, $this->appName, 'currentView', $defaultInitialView)); $showWeekends = $this->config->getUserValue($this->userId, $this->appName, 'showWeekends', $defaultShowWeekends) === 'yes'; $showWeekNumbers = $this->config->getUserValue($this->userId, $this->appName, 'showWeekNr', $defaultWeekNumbers) === 'yes'; $skipPopover = $this->config->getUserValue($this->userId, $this->appName, 'skipPopover', $defaultSkipPopover) === 'yes'; - $timezone = $this->config->getUserValue($this->userId, $this->appName, 'timezone', $defaultTimezone); - $attachmentsFolder = $this->config->getUserValue($this->userId, 'dav', 'attachmentsFolder', '/Calendar'); $slotDuration = $this->config->getUserValue($this->userId, $this->appName, 'slotDuration', $defaultSlotDuration); - $defaultReminder = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminder', $defaultDefaultReminder); - $defaultReminderPartDay = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminderPartDay', $defaultReminder); - $defaultReminderFullDay = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminderFullDay', $defaultReminder); $showTasks = $this->config->getUserValue($this->userId, $this->appName, 'showTasks', $defaultShowTasks) === 'yes'; $tasksSidebar = $this->config->getUserValue($this->userId, $this->appName, 'tasksSidebar', $defaultTasksSidebar) === 'yes'; - $hideEventExport = $this->config->getAppValue($this->appName, 'hideEventExport', 'no') === 'yes'; $disableAppointments = $this->config->getAppValue($this->appName, 'disableAppointments', 'no') === 'yes'; - $forceEventAlarmType = $this->config->getAppValue($this->appName, 'forceEventAlarmType', ''); - if (!in_array($forceEventAlarmType, ['DISPLAY', 'EMAIL'], true)) { - $forceEventAlarmType = false; - } $canSubscribeLink = $this->config->getAppValue('dav', 'allow_calendar_link_subscriptions', 'yes') === 'yes'; - $showResources = $this->config->getAppValue($this->appName, 'showResources', 'yes') === 'yes'; $publicCalendars = $this->config->getAppValue($this->appName, 'publicCalendars', ''); - $talkApiVersion = version_compare($this->appManager->getAppVersion('spreed'), '12.0.0', '>=') ? 'v4' : 'v1'; $tasksEnabled = $this->appManager->isEnabledForUser('tasks'); - $circleVersion = $this->appManager->getAppVersion('circles'); - $isCirclesEnabled = $this->appManager->isEnabledForUser('circles') === true; - // if circles is not installed, we use 0.0.0 - $isCircleVersionCompatible = $this->compareVersion->isCompatible($circleVersion ? $circleVersion : '0.0.0', '22'); - $calendarFederationEnabled = $this->appConfig->getValueBool( 'dav', 'enableCalendarFederation', @@ -96,46 +80,84 @@ public function run(): void { true, ); - $enableResourceBooking = !empty($this->resourceManager->getBackends()) - || !empty($this->roomManager->getBackends()); - - $this->initialStateService->provideInitialState('app_version', $appVersion); $this->initialStateService->provideInitialState('event_limit', $eventLimit); $this->initialStateService->provideInitialState('first_run', $firstRun); $this->initialStateService->provideInitialState('initial_view', $initialView); $this->initialStateService->provideInitialState('show_weekends', $showWeekends); $this->initialStateService->provideInitialState('show_week_numbers', $showWeekNumbers); $this->initialStateService->provideInitialState('skip_popover', $skipPopover); - $this->initialStateService->provideInitialState('talk_enabled', $this->isTalkEnabledForUser()); - $this->initialStateService->provideInitialState('talk_api_version', $talkApiVersion); - $this->initialStateService->provideInitialState('timezone', $timezone); - $this->initialStateService->provideInitialState('attachments_folder', $attachmentsFolder); $this->initialStateService->provideInitialState('slot_duration', $slotDuration); - $this->initialStateService->provideInitialState('default_reminder', $defaultReminder); - $this->initialStateService->provideInitialState('default_reminder_part_day', $defaultReminderPartDay); - $this->initialStateService->provideInitialState('default_reminder_full_day', $defaultReminderFullDay); $this->initialStateService->provideInitialState('show_tasks', $showTasks); $this->initialStateService->provideInitialState('tasks_sidebar', $tasksSidebar); $this->initialStateService->provideInitialState('tasks_enabled', $tasksEnabled); - $this->initialStateService->provideInitialState('hide_event_export', $hideEventExport); - $this->initialStateService->provideInitialState('force_event_alarm_type', $forceEventAlarmType); if (!is_null($this->userId)) { $this->initialStateService->provideInitialState('appointmentConfigs', $this->appointmentConfigService->getAllAppointmentConfigurations($this->userId)); } $this->initialStateService->provideInitialState('disable_appointments', $disableAppointments); $this->initialStateService->provideInitialState('can_subscribe_link', $canSubscribeLink); - $this->initialStateService->provideInitialState('show_resources', $showResources); - $this->initialStateService->provideInitialState('isCirclesEnabled', $isCirclesEnabled && $isCircleVersionCompatible); $this->initialStateService->provideInitialState('publicCalendars', $publicCalendars); $this->initialStateService->provideInitialState( 'calendar_federation_enabled', $calendarFederationEnabled && $remoteSharesEnabled, ); + $this->initialStateService->provideInitialState('has_notify_push', $this->queue !== null); + } + + /** + * Provide the state required by the event editor. + * + * This is the subset of {@see self::run()} needed on pages that do not render + * the calendar itself, but can spawn the editor - like the new event dialog of + * the app menu, which is available in every app. + */ + public function runForEditor(): void { + if ($this->editorStateProvided) { + return; + } + $this->editorStateProvided = true; + + $defaultTimezone = $this->config->getAppValue($this->appName, 'timezone', 'automatic'); + $defaultDefaultReminder = $this->config->getAppValue($this->appName, 'defaultReminder', 'none'); + + $appVersion = $this->config->getAppValue($this->appName, 'installed_version', ''); + $timezone = $this->config->getUserValue($this->userId, $this->appName, 'timezone', $defaultTimezone); + $attachmentsFolder = $this->config->getUserValue($this->userId, 'dav', 'attachmentsFolder', '/Calendar'); + $defaultReminder = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminder', $defaultDefaultReminder); + $defaultReminderPartDay = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminderPartDay', $defaultReminder); + $defaultReminderFullDay = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminderFullDay', $defaultReminder); + $hideEventExport = $this->config->getAppValue($this->appName, 'hideEventExport', 'no') === 'yes'; + $showResources = $this->config->getAppValue($this->appName, 'showResources', 'yes') === 'yes'; + $forceEventAlarmType = $this->config->getAppValue($this->appName, 'forceEventAlarmType', ''); + if (!in_array($forceEventAlarmType, ['DISPLAY', 'EMAIL'], true)) { + $forceEventAlarmType = false; + } + + $talkApiVersion = version_compare($this->appManager->getAppVersion('spreed'), '12.0.0', '>=') ? 'v4' : 'v1'; + + $circleVersion = $this->appManager->getAppVersion('circles'); + $isCirclesEnabled = $this->appManager->isEnabledForUser('circles') === true; + // if circles is not installed, we use 0.0.0 + $isCircleVersionCompatible = $this->compareVersion->isCompatible($circleVersion ? $circleVersion : '0.0.0', '22'); + + $enableResourceBooking = !empty($this->resourceManager->getBackends()) + || !empty($this->roomManager->getBackends()); + + $this->initialStateService->provideInitialState('app_version', $appVersion); + $this->initialStateService->provideInitialState('timezone', $timezone); + $this->initialStateService->provideInitialState('attachments_folder', $attachmentsFolder); + $this->initialStateService->provideInitialState('default_reminder', $defaultReminder); + $this->initialStateService->provideInitialState('default_reminder_part_day', $defaultReminderPartDay); + $this->initialStateService->provideInitialState('default_reminder_full_day', $defaultReminderFullDay); + $this->initialStateService->provideInitialState('hide_event_export', $hideEventExport); + $this->initialStateService->provideInitialState('show_resources', $showResources); + $this->initialStateService->provideInitialState('force_event_alarm_type', $forceEventAlarmType); + $this->initialStateService->provideInitialState('talk_enabled', $this->isTalkEnabledForUser()); + $this->initialStateService->provideInitialState('talk_api_version', $talkApiVersion); + $this->initialStateService->provideInitialState('isCirclesEnabled', $isCirclesEnabled && $isCircleVersionCompatible); $this->initialStateService->provideInitialState( 'resource_booking_enabled', $enableResourceBooking, ); - $this->initialStateService->provideInitialState('has_notify_push', $this->queue !== null); } /** diff --git a/rspack.config.js b/rspack.config.js index e8b178de20..edf38c1b26 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -58,6 +58,7 @@ module.exports = defineConfig((env) => { entry: { main: path.join(__dirname, 'src', 'main.js'), + appMenu: path.join(__dirname, 'src', 'app-menu.ts'), reference: path.join(__dirname, 'src', 'reference.js'), 'contacts-menu': path.join(__dirname, 'src', 'contactsMenu.js'), 'appointments-booking': path.join(__dirname, 'src', 'appointments', 'main-booking.js'), diff --git a/src/app-menu.ts b/src/app-menu.ts new file mode 100644 index 0000000000..34790614ca --- /dev/null +++ b/src/app-menu.ts @@ -0,0 +1,85 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCSPNonce } from '@nextcloud/auth' +import { subscribe } from '@nextcloud/event-bus' +import { translate, translatePlural } from '@nextcloud/l10n' +import { linkTo } from '@nextcloud/router' +import logger from './utils/logger.js' + +declare module '@nextcloud/event-bus' { + interface NextcloudEvents { + 'core:navigation:action': { id: string } + } +} + +__webpack_nonce__ = getCSPNonce()! +__webpack_public_path__ = linkTo('calendar', 'js/') + +/** Id of the app menu action registered by `Application::registerNewEventAction` */ +const NEW_EVENT_ACTION_ID = 'calendar:new-event' + +/** The currently open dialog, if any */ +let dialog: Promise | undefined + +subscribe('core:navigation:action', handleAppMenuActionClick) + +/** + * Handle clicks on app menu actions. + * + * @param action The action that was clicked + * @param action.id The id of the clicked action + */ +function handleAppMenuActionClick(action: { id: string }): void { + if (action.id !== NEW_EVENT_ACTION_ID || dialog !== undefined) { + return + } + + logger.debug('Opening the new event dialog') + dialog = openNewEventDialog() + .catch((error) => logger.error('Could not open the new event dialog', { error })) + .then(() => { + dialog = undefined + }) +} + +/** + * Mount the new event dialog, the promise resolves once it was closed again. + * + * This entry point is loaded on every page, so everything the editor needs + * is only loaded when the dialog is actually requested. + */ +async function openNewEventDialog(): Promise { + const [{ createApp }, { createPinia }, { default: NewEventDialog }] = await Promise.all([ + import('vue'), + import('pinia'), + import('./views/NewEventDialog.vue'), + ]) + + const element = document.body.appendChild(document.createElement('div')) + + await new Promise((resolve) => { + const app = createApp(NewEventDialog, { + onClose() { + app.unmount() + element.remove() + resolve() + }, + }) + + // The calendar app and nextcloud-vue still rely on the global translation functions + app.config.globalProperties.$t = translate + app.config.globalProperties.$n = translatePlural + app.config.globalProperties.t = translate + app.config.globalProperties.n = translatePlural + + app.config.errorHandler = (error, _vm, info) => { + logger.error(`[Vue error]: Error in ${info}: ${error}`, { error, info }) + } + + app.use(createPinia()) + app.mount(element) + }) +} diff --git a/src/components/Editor/Resources/ResourceList.vue b/src/components/Editor/Resources/ResourceList.vue index 3f854ea822..d4e0adf824 100644 --- a/src/components/Editor/Resources/ResourceList.vue +++ b/src/components/Editor/Resources/ResourceList.vue @@ -140,7 +140,8 @@ export default { }, resourceBookingEnabled() { - return loadState('calendar', 'resource_booking_enabled') + // The editor can be opened on pages without the calendar initial state + return loadState('calendar', 'resource_booking_enabled', false) }, }, diff --git a/src/mixins/EditorMixin.js b/src/mixins/EditorMixin.js index 1b1a4c9615..5d9a160183 100644 --- a/src/mixins/EditorMixin.js +++ b/src/mixins/EditorMixin.js @@ -20,7 +20,11 @@ import { removeMailtoPrefix } from '../utils/attendee.js' import { uidToHexColor } from '../utils/color.js' import { dateFactory } from '../utils/date.js' import logger from '../utils/logger.js' -import { getPrefixedRoute } from '../utils/router.js' +import { + getDefaultEndDateForNewEvent, + getDefaultStartDateForNewEvent, + getPrefixedRoute, +} from '../utils/router.js' /** * This is a mixin for the editor. It contains common Vue stuff, that is @@ -35,7 +39,19 @@ export default { type: Boolean, default: false, }, + + // Whether the editor is spawned as a standalone dialog, meaning there is + // no calendar view and no router to navigate back to + isDialog: { + type: Boolean, + default: false, + }, }, + + emits: [ + 'close', + ], + data() { return { // Indicator whether or not the event is currently loading, saving or being deleted @@ -446,7 +462,8 @@ export default { // Check if this is a new event or existing event based on route name // NewPopoverView and NewFullView are for new events - const isNewEvent = this.$route?.name?.startsWith('New') + // A standalone dialog is always for a new event + const isNewEvent = this.isDialog || this.$route?.name?.startsWith('New') if (isNewEvent) { // For new events, create a new calendar object instance @@ -454,9 +471,10 @@ export default { try { await this.loadingCalendars() - const isAllDay = (this.$route.params.allDay === '1') - const start = parseInt(this.$route.params.dtstart) - const end = parseInt(this.$route.params.dtend) + // Without a route, e.g. in a dialog, fall back to the next full hour + const isAllDay = (this.$route?.params.allDay === '1') + const start = parseInt(this.$route?.params.dtstart ?? getDefaultStartDateForNewEvent(), 10) + const end = parseInt(this.$route?.params.dtend ?? getDefaultEndDateForNewEvent(), 10) const timezoneId = this.settingsStore.getResolvedTimezone await this.calendarObjectInstanceStore.getCalendarObjectInstanceForNewEvent({ @@ -574,6 +592,11 @@ export default { this.widgetStore.closeWidgetEventDetails() return } + if (this.isDialog) { + this.calendarObjectInstanceStore.resetCalendarObjectInstanceObjectIdAndRecurrenceId() + this.$emit('close') + return + } const params = { ...this.$route.params } delete params.object delete params.recurrenceId diff --git a/src/services/editorBootstrapService.ts b/src/services/editorBootstrapService.ts new file mode 100644 index 0000000000..a7ca7f1aa5 --- /dev/null +++ b/src/services/editorBootstrapService.ts @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { translate as t } from '@nextcloud/l10n' +import useCalendarsStore from '../store/calendars.js' +import usePrincipalsStore from '../store/principals.js' +import useSettingsStore from '../store/settings.js' +import { uidToHexColor } from '../utils/color.js' +import logger from '../utils/logger.js' +import loadMomentLocalization from '../utils/moment.js' +import { getSettingsFromInitialState } from '../utils/settings.js' +import { initializeClientForUserView } from './caldavService.js' + +/** + * Prepare the stores needed to run the event editor standalone, that is outside + * of the calendar app where `Calendar.vue` takes care of this setup. + * + * Requires an active pinia instance, so this must be called from a component + * of the app the editor is mounted in. + */ +export async function bootstrapEditor(): Promise { + const settingsStore = useSettingsStore() + const principalsStore = usePrincipalsStore() + const calendarsStore = useCalendarsStore() + + settingsStore.loadSettingsFromServer(getSettingsFromInitialState()) + settingsStore.initializeCalendarJsConfig() + settingsStore.setMomentLocale({ locale: await loadMomentLocalization() }) + + await initializeClientForUserView() + await principalsStore.fetchCurrentUserPrincipal() + + const { calendars } = await calendarsStore.loadCollections() + logger.debug('Calendars loaded for the event editor', { calendars }) + + // The owners are needed to tell delegated calendars apart + for (const owner of new Set(calendars.map((calendar) => calendar.owner))) { + principalsStore.fetchPrincipalByUrl({ url: owner }) + } + + // A new event has to be created somewhere, same as in the calendar app itself + if (!calendars.some((calendar) => !calendar.readOnly)) { + logger.info('User has no writable calendar, a new personal calendar will be created') + await calendarsStore.appendCalendar({ + displayName: t('calendar', 'Personal'), + color: uidToHexColor(t('calendar', 'Personal')), + order: 0, + }) + } + + // Not awaited, the editor picks them up as soon as they are available + if (settingsStore.showResources) { + principalsStore.fetchRoomAndResourcePrincipals() + .catch((error: unknown) => logger.error('Could not fetch rooms and resources', { error })) + } +} diff --git a/src/store/calendarObjects.js b/src/store/calendarObjects.js index c5d3ae477e..4608e638a6 100644 --- a/src/store/calendarObjects.js +++ b/src/store/calendarObjects.js @@ -3,7 +3,6 @@ import { DateTimeValue, getParserManager, } from '@nextcloud/calendar-js' -import { getTimezoneManager } from '@nextcloud/timezones' import { defineStore } from 'pinia' import { markRaw } from 'vue' /** @@ -11,6 +10,8 @@ import { markRaw } from 'vue' * SPDX-License-Identifier: AGPL-3.0-or-later */ import { mapCalendarJsToCalendarObject } from '../models/calendarObject.js' +// The manager has to be requested from the service, it registers the timezone data +import getTimezoneManager from '../services/timezoneDataProviderService.js' import logger from '../utils/logger.js' import useCalendarObjectInstanceStore from './calendarObjectInstance.js' import useCalendarsStore from './calendars.js' diff --git a/src/styles.d.ts b/src/styles.d.ts new file mode 100644 index 0000000000..ada204b781 --- /dev/null +++ b/src/styles.d.ts @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// Stylesheets are handled by the bundler, they only need to be importable +declare module '*.css' +declare module '*.scss' diff --git a/src/styles/calendar.scss b/src/styles/calendar.scss index 57dedd6a2b..7979946cf2 100644 --- a/src/styles/calendar.scss +++ b/src/styles/calendar.scss @@ -3,13 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ @use 'app-navigation.scss'; -@use 'app-full.scss'; @use 'app-settings.scss'; @use 'app-modal.scss'; -@use 'freebusy.scss'; +@use 'editor.scss'; @use 'fullcalendar.scss'; @use 'global.scss'; @use 'import.scss'; @use 'print.scss'; @use 'public.scss'; -@use 'props-linkify-links.scss'; diff --git a/src/styles/editor.scss b/src/styles/editor.scss new file mode 100644 index 0000000000..bd96c4068c --- /dev/null +++ b/src/styles/editor.scss @@ -0,0 +1,11 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// Global styles of the event editor. +// Kept separate from `calendar.scss` because the editor can be opened +// standalone from any other app, where the calendar view is never rendered. +@use 'app-full.scss'; +@use 'freebusy.scss'; +@use 'props-linkify-links.scss'; diff --git a/src/utils/settings.js b/src/utils/settings.js index 632b115c88..74d83b0184 100644 --- a/src/utils/settings.js +++ b/src/utils/settings.js @@ -2,8 +2,46 @@ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { loadState } from '@nextcloud/initial-state' import { linkTo } from '@nextcloud/router' +/** + * Get the settings provided as initial state by the server. + * + * The calendar initial state is not available on every page - the editor can be + * opened from any app - so every value falls back to the server side default. + * + * @return {object} Settings as expected by the `loadSettingsFromServer` action + */ +export function getSettingsFromInitialState() { + const defaultReminder = loadState('calendar', 'default_reminder', 'none') + + return { + appVersion: loadState('calendar', 'app_version', ''), + eventLimit: loadState('calendar', 'event_limit', true), + firstRun: loadState('calendar', 'first_run', false), + showWeekends: loadState('calendar', 'show_weekends', true), + showWeekNumbers: loadState('calendar', 'show_week_numbers', false), + skipPopover: loadState('calendar', 'skip_popover', false), + slotDuration: loadState('calendar', 'slot_duration', '00:30:00'), + defaultReminder, + defaultReminderPartDay: loadState('calendar', 'default_reminder_part_day', defaultReminder), + defaultReminderFullDay: loadState('calendar', 'default_reminder_full_day', defaultReminder), + talkEnabled: loadState('calendar', 'talk_enabled', false), + tasksEnabled: loadState('calendar', 'tasks_enabled', false), + timezone: loadState('calendar', 'timezone', 'automatic'), + showTasks: loadState('calendar', 'show_tasks', false), + hideEventExport: loadState('calendar', 'hide_event_export', false), + forceEventAlarmType: loadState('calendar', 'force_event_alarm_type', false), + disableAppointments: loadState('calendar', 'disable_appointments', false), + canSubscribeLink: loadState('calendar', 'can_subscribe_link', false), + attachmentsFolder: loadState('calendar', 'attachments_folder', '/Calendar'), + showResources: loadState('calendar', 'show_resources', true), + publicCalendars: loadState('calendar', 'publicCalendars', []), + tasksSidebar: loadState('calendar', 'tasks_sidebar', true), + } +} + /** * Get URL to modify config-key * diff --git a/src/views/Calendar.vue b/src/views/Calendar.vue index 0653d9bd56..6ce9870f23 100644 --- a/src/views/Calendar.vue +++ b/src/views/Calendar.vue @@ -94,7 +94,6 @@ import { showWarning, } from '@nextcloud/dialogs' -import { loadState } from '@nextcloud/initial-state' // Import vue components import { NcAppContent as AppContent, @@ -147,6 +146,7 @@ import { import logger from '../utils/logger.js' import loadMomentLocalization from '../utils/moment.js' import { isAfterVersion } from '../utils/nextcloudVersion.ts' +import { getSettingsFromInitialState } from '../utils/settings.js' import '@nextcloud/dialogs/style.css' @@ -322,30 +322,7 @@ export default { }, async beforeMount() { - this.settingsStore.loadSettingsFromServer({ - appVersion: loadState('calendar', 'app_version'), - eventLimit: loadState('calendar', 'event_limit'), - firstRun: loadState('calendar', 'first_run'), - showWeekends: loadState('calendar', 'show_weekends'), - showWeekNumbers: loadState('calendar', 'show_week_numbers'), - skipPopover: loadState('calendar', 'skip_popover'), - slotDuration: loadState('calendar', 'slot_duration'), - defaultReminder: loadState('calendar', 'default_reminder'), - defaultReminderPartDay: loadState('calendar', 'default_reminder_part_day', loadState('calendar', 'default_reminder')), - defaultReminderFullDay: loadState('calendar', 'default_reminder_full_day', loadState('calendar', 'default_reminder')), - talkEnabled: loadState('calendar', 'talk_enabled'), - tasksEnabled: loadState('calendar', 'tasks_enabled'), - timezone: loadState('calendar', 'timezone'), - showTasks: loadState('calendar', 'show_tasks'), - hideEventExport: loadState('calendar', 'hide_event_export'), - forceEventAlarmType: loadState('calendar', 'force_event_alarm_type', false), - disableAppointments: loadState('calendar', 'disable_appointments', false), - canSubscribeLink: loadState('calendar', 'can_subscribe_link', false), - attachmentsFolder: loadState('calendar', 'attachments_folder', false), - showResources: loadState('calendar', 'show_resources', true), - publicCalendars: loadState('calendar', 'publicCalendars', []), - tasksSidebar: loadState('calendar', 'tasks_sidebar', true), - }) + this.settingsStore.loadSettingsFromServer(getSettingsFromInitialState()) this.settingsStore.initializeCalendarJsConfig() if (this.$route?.name.startsWith('Public') || this.$route?.name.startsWith('Embed') || this.isPublic) { diff --git a/src/views/NewEventDialog.vue b/src/views/NewEventDialog.vue new file mode 100644 index 0000000000..4dc5f1e12f --- /dev/null +++ b/src/views/NewEventDialog.vue @@ -0,0 +1,67 @@ + + + + + + +