Skip to content

Commit 4b7f9f6

Browse files
committed
feat(importing): show all writable calendars in picker
Mark unsupported calendars as non-selectable. Previously they were fully hidden. Show a message about why some calendars are not selectable. Also show calendars that do not support events (and only support tasks and/or journal entries). Previously, calendars that did not support events were hidden altogether. Resolves: #2572 Signed-off-by: Oleksandr Dzhychko <hey@oleks.dev>
1 parent eee2f68 commit 4b7f9f6

5 files changed

Lines changed: 151 additions & 62 deletions

File tree

css/import.scss

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121

2222
.import-modal-file-item {
2323
display: flex;
24-
padding-top: 10px;
24+
margin-top: calc(var(--default-grid-baseline) * 2);
25+
margin-bottom: calc(var(--default-grid-baseline) * 2);
2526

2627
&--header {
2728
font-weight: bold;
@@ -34,6 +35,10 @@
3435
&__calendar-select {
3536
flex: 1 1 0;
3637
}
38+
39+
&__calendar-disabled-hint {
40+
color: var(--color-text-maxcontrast);
41+
}
3742
}
3843
}
3944
}

src/components/AppNavigation/Settings/ImportScreenRow.vue

Lines changed: 74 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,24 @@
66
<template>
77
<li class="import-modal-file-item">
88
<div class="import-modal-file-item__filename">
9-
{{ file.name }}
9+
<div>{{ file.name }}</div>
10+
<div
11+
v-if="disabledHint"
12+
class="import-modal-file-item__calendar-disabled-hint">
13+
{{ disabledHint }}
14+
</div>
1015
</div>
1116
<CalendarPicker
1217
class="import-modal-file-item__calendar-select"
1318
:value="calendar"
1419
:calendars="calendars"
20+
:isCalendarSelectable="isCalendarSelectable"
1521
@selectCalendar="selectCalendar" />
1622
</li>
1723
</template>
1824

1925
<script>
26+
import { getLanguage } from '@nextcloud/l10n'
2027
import { mapStores } from 'pinia'
2128
import CalendarPicker from '../../Shared/CalendarPicker.vue'
2229
import useCalendarsStore from '../../../store/calendars.js'
@@ -39,59 +46,90 @@ export default {
3946
4047
computed: {
4148
...mapStores(usePrincipalsStore, useImportFilesStore, useCalendarsStore),
42-
calendar() {
43-
let calendarId = this.importFilesStore.importCalendarRelation[this.file.id]
44-
if (!calendarId) {
45-
this.setDefaultCalendarId()
46-
calendarId = this.importFilesStore.importCalendarRelation[this.file.id]
49+
newCalendar() {
50+
return {
51+
id: 'new',
52+
displayName: this.$t('calendar', 'New calendar'),
53+
isSharedWithMe: false,
54+
color: uidToHexColor(this.$t('calendar', 'New calendar')),
55+
owner: this.principalsStore.getCurrentUserPrincipal.url,
4756
}
57+
},
4858
49-
if (calendarId === 'new') {
50-
return {
51-
id: 'new',
52-
displayName: this.$t('calendar', 'New calendar'),
53-
isSharedWithMe: false,
54-
color: uidToHexColor(this.$t('calendar', 'New calendar')),
55-
owner: this.principalsStore.getCurrentUserPrincipal.url,
56-
}
59+
calendar() {
60+
const calendarId = this.importFilesStore.importCalendarRelation[this.file.id]
61+
if (calendarId === this.newCalendar.id) {
62+
return this.newCalendar
5763
}
58-
5964
return this.calendarsStore.getCalendarById(calendarId)
6065
},
6166
6267
calendars() {
63-
// TODO: remove once the false positive is fixed upstream
68+
const existingCalendars = this.calendarsStore.sortedWritableCalendarsEvenWithoutSupportForEvents
69+
return [...existingCalendars, this.newCalendar]
70+
},
6471
65-
const calendars = this.calendarsStore.sortedCalendarFilteredByComponents(
66-
this.file.parser.containsVEvents(),
67-
this.file.parser.containsVJournals(),
68-
this.file.parser.containsVTodos(),
69-
)
72+
/**
73+
* Returns a hint explaining why some calendars cannot be selected.
74+
*
75+
* @return {string|undefined} A message, or undefined if all calendars can be selected.
76+
*/
77+
disabledHint() {
78+
const disalbedBecauseOfEvents = this.file.parser.containsVEvents() && this.calendars.some((calendar) => !calendar.supportsEvents)
79+
const disalbedBecauseOfTasks = this.file.parser.containsVTodos() && this.calendars.some((calendar) => !calendar.supportsTasks)
80+
const disalbedBecauseOfJournalEntries = this.file.parser.containsVJournals() && this.calendars.some((calendar) => !calendar.supportsJournals)
7081
71-
calendars.push({
72-
id: 'new',
73-
displayName: this.$t('calendar', 'New calendar'),
74-
isSharedWithMe: false,
75-
color: uidToHexColor(this.$t('calendar', 'New calendar')),
76-
owner: this.principalsStore.getCurrentUserPrincipal.url,
77-
})
82+
const disablingTypes = []
83+
if (disalbedBecauseOfEvents) {
84+
disablingTypes.push(this.$t('calendar', 'events'))
85+
}
86+
if (disalbedBecauseOfTasks) {
87+
disablingTypes.push(this.$t('calendar', 'tasks'))
88+
}
89+
if (disalbedBecauseOfJournalEntries) {
90+
disablingTypes.push(this.$t('calendar', 'journal entries'))
91+
}
7892
79-
return calendars
93+
if (disablingTypes.lenght === 0) {
94+
return undefined
95+
}
96+
97+
const formatter = new Intl.ListFormat(getLanguage(), { type: 'conjunction' })
98+
const localizedTypes = formatter.format(disablingTypes)
99+
return this.$t('calendar', 'Some calendars are disabled because this file contains {types}.', { types: localizedTypes })
80100
},
81101
},
82102
103+
created() {
104+
const preselectedCalendar = this.calendars.find((calendar) => this.isCalendarSelectable(calendar))
105+
if (!preselectedCalendar) {
106+
// If no other calendar is selectable, at least `this.newCalendar` should be selectable and be preselected.
107+
throw new Error('Encountered illegal state. At least one calendar that can be selected should exist.')
108+
}
109+
this.selectCalendar(preselectedCalendar)
110+
},
111+
83112
methods: {
84-
selectCalendar(newCalendar) {
85-
this.importFilesStore.setCalendarForFileId({
86-
fileId: this.file.id,
87-
calendarId: newCalendar.id,
88-
})
113+
isCalendarSelectable(calendar) {
114+
if (calendar.id === this.newCalendar.id) {
115+
return true
116+
}
117+
if (this.file.parser.containsVEvents() && !calendar.supportsEvents) {
118+
return false
119+
}
120+
if (this.file.parser.containsVTodos() && !calendar.supportsTasks) {
121+
return false
122+
}
123+
if (this.file.parser.containsVJournals() && !calendar.supportsJournals) {
124+
return false
125+
}
126+
return true
89127
},
90128
91-
setDefaultCalendarId() {
129+
selectCalendar(newCalendar) {
92130
this.importFilesStore.setCalendarForFileId({
93131
fileId: this.file.id,
94-
calendarId: this.calendars[0].id,
132+
calendarId: newCalendar.id,
95133
})
96134
},
97135
},

src/components/Shared/CalendarPicker.vue

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
:filterBy="selectFilterBy"
1616
:inputLabel="inputLabel"
1717
:labelOutside="inputLabel === ''"
18+
:selectable="selectable"
1819
@update:modelValue="handleSelectionUpdate">
1920
<template #option="{ id }">
2021
<CalendarPickerOption
@@ -87,6 +88,18 @@ export default {
8788
type: String,
8889
default: '',
8990
},
91+
92+
/**
93+
* Decides whether a calendar is selectable or not.
94+
* Non-selectable calendars are displayed but cannot be selected.
95+
*
96+
* @param {object} calendar
97+
* @return {boolean}
98+
*/
99+
isCalendarSelectable: {
100+
type: Function,
101+
default: (calendar) => true,
102+
},
90103
},
91104
92105
computed: {
@@ -177,6 +190,17 @@ export default {
177190
selectFilterBy(option, label, search) {
178191
return option.displayName.toLowerCase().indexOf(search) !== -1
179192
},
193+
194+
/**
195+
* Decide whether the given option can be selected
196+
*
197+
* @param {object} option The calendar option
198+
* @return {boolean} True if the option can be selected
199+
*/
200+
selectable(option) {
201+
const calendar = this.getCalendarById(option.id)
202+
return this.isCalendarSelectable(calendar)
203+
},
180204
},
181205
}
182206
</script>

src/store/calendars.js

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@ export default defineStore('calendars', {
9090
.sort((a, b) => a.order - b.order)
9191
},
9292

93+
/**
94+
* List of sorted writable calendars.
95+
*
96+
* Even including ones without support for events.
97+
* Those are usually excluded by all other getters.
98+
*
99+
* @param {object} state the store data
100+
* @return {Array}
101+
*/
102+
sortedWritableCalendarsEvenWithoutSupportForEvents(state) {
103+
return state.calendars
104+
.filter((calendar) => !calendar.readOnly)
105+
.sort((a, b) => a.order - b.order)
106+
},
107+
93108
/**
94109
* List of sorted calendars owned by the principal
95110
*
@@ -221,29 +236,6 @@ export default defineStore('calendars', {
221236
return null
222237
},
223238

224-
/**
225-
* @return {function({Boolean}, {Boolean}, {Boolean}): {Object}[]}
226-
*/
227-
sortedCalendarFilteredByComponents() {
228-
return (vevent, vjournal, vtodo) => {
229-
return this.sortedCalendars.filter((calendar) => {
230-
if (vevent && !calendar.supportsEvents) {
231-
return false
232-
}
233-
234-
if (vjournal && !calendar.supportsJournals) {
235-
return false
236-
}
237-
238-
if (vtodo && !calendar.supportsTasks) {
239-
return false
240-
}
241-
242-
return true
243-
})
244-
}
245-
},
246-
247239
/**
248240
* Get the current sync token of a calendar or undefined it the calendar is not present
249241
*

tests/javascript/unit/store/calendars.test.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,41 @@
22
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5+
import useCalendarsStore from '../../../../src/store/calendars.js'
6+
7+
import { setActivePinia, createPinia } from 'pinia'
58

69
describe('store/calendars test suite', () => {
10+
11+
beforeEach(() => {
12+
setActivePinia(createPinia())
13+
})
14+
15+
it('should provide a getter for all writable calendars sorted', () => {
16+
const calendarsStore = useCalendarsStore()
17+
const calendarOrderLast = {
18+
id: "1",
19+
order: 2,
20+
supportsEvents: false,
21+
supportsJournals: true
22+
}
23+
const calendarReadOnly = {
24+
id: "2",
25+
readOnly: true,
26+
supportsEvents: true,
27+
}
28+
const calendarOrderFirst = {
29+
id: "3",
30+
order: 1,
31+
supportsEvents: true,
32+
supportsJournals: false
33+
}
34+
calendarsStore.addCalendarMutation({ calendar: calendarOrderLast })
35+
calendarsStore.addCalendarMutation({ calendar: calendarReadOnly })
36+
calendarsStore.addCalendarMutation({ calendar: calendarOrderFirst })
737

8-
it('should be true', () => {
9-
expect(true).toEqual(true)
38+
writableCalendars = calendarsStore.sortedWritableCalendarsEvenWithoutSupportForEvents
39+
expect(writableCalendars).toMatchObject([calendarOrderFirst, calendarOrderLast])
1040
})
1141

1242
})

0 commit comments

Comments
 (0)