diff --git a/css/fullcalendar.scss b/css/fullcalendar.scss index 10bc0d22c9..fcd87b9e64 100644 --- a/css/fullcalendar.scss +++ b/css/fullcalendar.scss @@ -75,6 +75,7 @@ .fc-event { box-shadow: 0px 0px 0px 1px var(--color-primary-element-light) !important; + margin-top: 0px; } .fc-daygrid-day-top { @@ -131,6 +132,7 @@ // ### FullCalendar Event adjustments .fc-event { padding-left: 3px; + border-width: 2px; &.fc-event-nc-task-completed, &.fc-event-nc-tentative, @@ -149,6 +151,7 @@ .fc-event-title { text-overflow: ellipsis; + font-weight: 700; } // Reminder icon on events with alarms set @@ -200,6 +203,10 @@ max-width: 25vw; } + svg { + margin-right: 2px; + } + @media only screen and (max-width: 767px) { .fc-list-event-location, .fc-list-event-description { diff --git a/src/fullcalendar/eventSources/eventSourceFunction.js b/src/fullcalendar/eventSources/eventSourceFunction.js index efe78c3f4f..c4884659ae 100644 --- a/src/fullcalendar/eventSources/eventSourceFunction.js +++ b/src/fullcalendar/eventSources/eventSourceFunction.js @@ -11,7 +11,7 @@ import { } from '../../utils/color.js' import logger from '../../utils/logger.js' import { getAllObjectsInTimeRange } from '../../utils/calendarObject.js' - +import usePrincipalsStore from '../../store/principals.js' /** * convert an array of calendar-objects to events * @@ -23,6 +23,8 @@ import { getAllObjectsInTimeRange } from '../../utils/calendarObject.js' * @return {object}[] */ export function eventSourceFunction(calendarObjects, calendar, start, end, timezone) { + const principalsStore = usePrincipalsStore() + const fcEvents = [] for (const calendarObject of calendarObjects) { let allObjectsInTimeRange @@ -32,9 +34,26 @@ export function eventSourceFunction(calendarObjects, calendar, start, end, timez logger.error(error.message) continue } - for (const object of allObjectsInTimeRange) { const classNames = [] + let didEveryoneDecline = false + + // You are an organizer + if (object.getFirstPropertyFirstValue('ORGANIZER') === `mailto:${principalsStore.getCurrentUserPrincipalEmail}`) { + // Check if all the attendees have declined the event + if (object.hasProperty('ATTENDEE')) { + didEveryoneDecline = true + for (const attendeeProperty of object.getPropertyIterator('ATTENDEE')) { + const hasDeclined = attendeeProperty.participationStatus === 'DECLINED' + if (!hasDeclined) { + didEveryoneDecline = false + } + } + if (didEveryoneDecline) { + classNames.push('fc-event-nc-all-declined') + } + } + } if (object.status === 'CANCELLED') { classNames.push('fc-event-nc-cancelled') @@ -42,6 +61,19 @@ export function eventSourceFunction(calendarObjects, calendar, start, end, timez classNames.push('fc-event-nc-tentative') } + // You are invited + for (const attendeeProperty of object.getPropertyIterator('ATTENDEE')) { + if (attendeeProperty.email === `mailto:${principalsStore.getCurrentUserPrincipalEmail}`) { + if (attendeeProperty.participationStatus === 'DECLINED') { + classNames.push('fc-event-nc-declined') + } else if (attendeeProperty.participationStatus === 'TENTATIVE') { + classNames.push('fc-event-nc-tentative') + } else if (attendeeProperty.participationStatus === 'NEEDS-ACTION') { + classNames.push('fc-event-nc-needs-action') + } + } + } + if (object.hasComponent('VALARM')) { classNames.push('fc-event-nc-alarms') } diff --git a/src/fullcalendar/rendering/eventDidMount.js b/src/fullcalendar/rendering/eventDidMount.js index fe066f952d..c2fbe35d04 100644 --- a/src/fullcalendar/rendering/eventDidMount.js +++ b/src/fullcalendar/rendering/eventDidMount.js @@ -29,7 +29,7 @@ export default function({ event, el }) { const dotElement = el.querySelector('.fc-list-event-dot') dotElement.classList.remove('fc-list-event-dot') dotElement.classList.add('fc-list-event-checkbox') - dotElement.style.color = dotElement.style.borderColor + dotElement.style.color = 'var(--color-main-text)' if (event.extendedProps.percent === 100) { dotElement.classList.add('calendar-grid-checkbox-checked') @@ -41,7 +41,7 @@ export default function({ event, el }) { const dotElement = el.querySelector('.fc-daygrid-event-dot') dotElement.classList.remove('fc-daygrid-event-dot') dotElement.classList.add('fc-daygrid-event-checkbox') - dotElement.style.color = dotElement.style.borderColor + dotElement.style.color = 'var(--color-main-text)' if (event.extendedProps.percent === 100) { dotElement.classList.add('calendar-grid-checkbox-checked') @@ -91,4 +91,80 @@ export default function({ event, el }) { descriptionContainer.appendChild(description) } } + + if ( + el.classList.contains('fc-event-nc-all-declined') + || el.classList.contains('fc-event-nc-needs-action') + || el.classList.contains('fc-event-nc-declined') + ) { + const titleElement = el.querySelector('.fc-event-title') + const dotElement = el.querySelector('.fc-daygrid-event-dot') + + if (dotElement) { + dotElement.style.borderWidth = '2px' + dotElement.style.background = 'transparent' + dotElement.style.minWidth = '10px' + dotElement.style.minHeight = '10px' + } + + titleElement.style.color = 'var(--color-main-text)' + el.style.background = 'transparent' + el.title = t('calendar', 'All participants declined') + + if (el.classList.contains('fc-event-nc-needs-action')) { + el.title = t('calendar', 'Please confirm your participation') + } + + if (el.classList.contains('fc-event-nc-declined')) { + el.title = t('calendar', 'You declined this event') + titleElement.style.textDecoration = 'line-through' + } + } + + if (el.classList.contains('fc-event-nc-all-declined')) { + const titleElement = el.querySelector('.fc-event-title') + + const svgString = '' + titleElement.innerHTML = svgString + titleElement.innerHTML + + const svgElement = titleElement.querySelector('svg') + svgElement.style.fill = el.style.borderColor + svgElement.style.width = '1em' + svgElement.style.marginBottom = '0.2em' + svgElement.style.verticalAlign = 'middle' + } + + if (el.classList.contains('fc-event-nc-tentative')) { + const dotElement = el.querySelector('.fc-daygrid-event-dot') + + const bgColor = el.style.backgroundColor ? el.style.backgroundColor : dotElement.style.borderColor + const bgStripeColor = darkenColor(bgColor) + + let backgroundStyling = `repeating-linear-gradient(45deg, ${bgStripeColor}, ${bgStripeColor} 1px, ${bgColor} 1px, ${bgColor} 10px)` + + if (dotElement) { + dotElement.style.borderWidth = '2px' + backgroundStyling = `repeating-linear-gradient(45deg, ${bgColor}, ${bgColor} 1px, transparent 1px, transparent 3.5px)` + + dotElement.style.background = backgroundStyling + dotElement.style.minWidth = '10px' + dotElement.style.minHeight = '10px' + } else { + el.style.background = backgroundStyling + } + + el.title = t('calendar', 'Your participation is tentative') + } +} + +/** + * Create a slightly darker color for background stripes + * + * @param {string} color The color to darken + */ +function darkenColor(color) { + const rgb = color.match(/\d+/g) + if (!rgb) return color + const [r, g, b] = rgb.map(c => Math.max(0, Math.min(255, c - (c * 0.3)))) + return `rgb(${r}, ${g}, ${b})` } diff --git a/tests/javascript/unit/fullcalendar/eventSources/freeBusyResourceEventSourceFunction.test.js b/tests/javascript/unit/fullcalendar/eventSources/freeBusyResourceEventSourceFunction.test.js index fd20cc3e3b..965d3c25dc 100644 --- a/tests/javascript/unit/fullcalendar/eventSources/freeBusyResourceEventSourceFunction.test.js +++ b/tests/javascript/unit/fullcalendar/eventSources/freeBusyResourceEventSourceFunction.test.js @@ -11,6 +11,7 @@ import { } from '../../../../../src/utils/color.js' import { translate } from '@nextcloud/l10n' import {getAllObjectsInTimeRange} from "../../../../../src/utils/calendarObject.js"; +import { createPinia, setActivePinia } from 'pinia' jest.mock('@nextcloud/l10n') jest.mock('../../../../../src/utils/color.js') jest.mock("../../../../../src/utils/calendarObject.js") @@ -22,6 +23,7 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { getHexForColorName.mockClear() generateTextColorForHex.mockClear() getAllObjectsInTimeRange.mockClear() + setActivePinia(createPinia()) }) it('should provide fc-events', () => { @@ -63,6 +65,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { }) }, hasComponent: jest.fn().mockReturnValue(false), + getFirstPropertyFirstValue: jest.fn().mockReturnValue(null), + getPropertyIterator: jest.fn().mockReturnValue([]), }, { name: 'VEVENT', id: '1-2', @@ -82,6 +86,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { }, hasComponent: jest.fn().mockReturnValue(false), title: 'Untitled\nmultiline\nevent', + getFirstPropertyFirstValue: jest.fn().mockReturnValue(null), + getPropertyIterator: jest.fn().mockReturnValue([]), }, { name: 'VEVENT', id: '1-3', @@ -100,6 +106,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { }) }, hasComponent: jest.fn().mockReturnValue(true), + getFirstPropertyFirstValue: jest.fn().mockReturnValue(null), + getPropertyIterator: jest.fn().mockReturnValue([]), }] const eventComponentSet2 = [{ name: 'VEVENT', @@ -119,6 +127,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { }) }, hasComponent: jest.fn().mockReturnValue(false), + getFirstPropertyFirstValue: jest.fn().mockReturnValue(null), + getPropertyIterator: jest.fn().mockReturnValue([]), }] const eventComponentSet4 = [{ name: 'VEVENT', @@ -139,6 +149,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { }, hasComponent: jest.fn().mockReturnValue(false), color: 'red', + getFirstPropertyFirstValue: jest.fn().mockReturnValue(null), + getPropertyIterator: jest.fn().mockReturnValue([]), }] getAllObjectsInTimeRange @@ -202,6 +214,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { davUrl: 'url1', objectType: 'VEVENT', percent: null, + description: undefined, + location: undefined, } }, { @@ -222,6 +236,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { davUrl: 'url1', objectType: 'VEVENT', percent: null, + description: undefined, + location: undefined, } }, { @@ -242,6 +258,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { davUrl: 'url1', objectType: 'VEVENT', percent: null, + description: undefined, + location: undefined, } }, { @@ -262,6 +280,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { davUrl: 'url2', objectType: 'VEVENT', percent: null, + description: undefined, + location: undefined, } }, { @@ -282,6 +302,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { davUrl: 'url4', objectType: 'VEVENT', percent: null, + description: undefined, + location: undefined, }, backgroundColor: '#ff0000', borderColor: '#ff0000', @@ -445,6 +467,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { }, hasComponent: jest.fn().mockReturnValue(false), percent: null, + getFirstPropertyFirstValue: jest.fn().mockReturnValue(null), + getPropertyIterator: jest.fn().mockReturnValue([]), }, { name: 'VTODO', id: '2', @@ -464,6 +488,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { }, hasComponent: jest.fn().mockReturnValue(false), percent: null, + getFirstPropertyFirstValue: jest.fn().mockReturnValue(null), + getPropertyIterator: jest.fn().mockReturnValue([]), }, { name: 'VTODO', id: '3', @@ -483,6 +509,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { }, hasComponent: jest.fn().mockReturnValue(false), percent: 99, + getFirstPropertyFirstValue: jest.fn().mockReturnValue(null), + getPropertyIterator: jest.fn().mockReturnValue([]), }, { name: 'VTODO', id: '4', @@ -503,6 +531,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { hasComponent: jest.fn().mockReturnValue(false), title: 'This task has a title', percent: null, + getFirstPropertyFirstValue: jest.fn().mockReturnValue(null), + getPropertyIterator: jest.fn().mockReturnValue([]), }, { name: 'VTODO', id: '5', @@ -523,6 +553,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { hasComponent: jest.fn().mockReturnValue(false), title: 'This task has a title and percent', percent: 99, + getFirstPropertyFirstValue: jest.fn().mockReturnValue(null), + getPropertyIterator: jest.fn().mockReturnValue([]), }, { name: 'VTODO', id: '6', @@ -535,6 +567,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { startDate: null, endDate: null, percent: null, + getFirstPropertyFirstValue: jest.fn().mockReturnValue(null), + getPropertyIterator: jest.fn().mockReturnValue([]), }] getAllObjectsInTimeRange @@ -573,6 +607,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { objectType: 'VTODO', percent: null, recurrenceId: 123, + description: undefined, + location: undefined, }, id: '1###1', start: event1End, @@ -594,6 +630,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { objectType: 'VTODO', percent: null, recurrenceId: 123, + description: undefined, + location: undefined, }, id: '1###2', start: event2End, @@ -615,6 +653,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { objectType: 'VTODO', percent: 99, recurrenceId: 123, + description: undefined, + location: undefined, }, id: '1###3', start: event3End, @@ -636,6 +676,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { objectType: 'VTODO', percent: null, recurrenceId: 123, + description: undefined, + location: undefined, }, id: '1###4', start: event4End, @@ -657,6 +699,8 @@ describe('fullcalendar/freeBusyResourceEventSourceFunction test suite', () => { objectType: 'VTODO', percent: 99, recurrenceId: 123, + description: undefined, + location: undefined, }, id: '1###5', start: event5End,