Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(calendar): allow to select meeting attendees #14097

Merged
merged 2 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/components/BreakoutRoomsEditor/SelectableParticipant.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
@keydown.enter.stop.prevent="handleEnter">
<!-- Participant's avatar -->
<AvatarWrapper :id="actorId"
token="new"
:token="participant.roomToken ?? 'new'"
:name="computedName"
:source="actorType"
disable-menu
Expand Down Expand Up @@ -44,6 +44,7 @@ import { t } from '@nextcloud/l10n'

import AvatarWrapper from '../AvatarWrapper/AvatarWrapper.vue'

import { ATTENDEE } from '../../constants.js'
import { getPreloadedUserStatus, getStatusMessage } from '../../utils/userStatus.ts'

export default {
Expand Down Expand Up @@ -110,14 +111,17 @@ export default {
},

computedName() {
return this.participant.displayName || this.participant.label
return this.participant.displayName || this.participant.label || t('spreed', 'Guest')
},

preloadedUserStatus() {
return getPreloadedUserStatus(this.participant)
},

participantStatus() {
if (this.actorType === ATTENDEE.ACTOR_TYPE.EMAILS) {
return this.participant.invitedActorId
}
Comment on lines +122 to +124
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking that current user is a moderator makes it safer to use in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be a task of this component IMO, but I'll put it on board

return this.participant.shareWithDisplayNameUnique
?? getStatusMessage(this.participant)
},
Expand Down
126 changes: 120 additions & 6 deletions src/components/CalendarEventsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@
-->

<script setup lang="ts">
import { computed, onBeforeMount, ref, watch } from 'vue'
import { computed, onBeforeMount, provide, ref, watch } from 'vue'

import IconAccountSearch from 'vue-material-design-icons/AccountSearch.vue'
import IconCalendarBlank from 'vue-material-design-icons/CalendarBlank.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import IconReload from 'vue-material-design-icons/Reload.vue'

import { t } from '@nextcloud/l10n'
import { showSuccess } from '@nextcloud/dialogs'
import { t, n } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'

import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
Expand All @@ -26,9 +29,14 @@ import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import { useIsMobile } from '@nextcloud/vue/dist/Composables/useIsMobile.js'
import usernameToColor from '@nextcloud/vue/dist/Functions/usernameToColor.js'

import SelectableParticipant from './BreakoutRoomsEditor/SelectableParticipant.vue'
import SearchBox from './UIShared/SearchBox.vue'

import { useStore } from '../composables/useStore.js'
import { ATTENDEE } from '../constants.js'
import { hasTalkFeature } from '../services/CapabilitiesManager.ts'
import { useGroupwareStore } from '../stores/groupware.ts'
import type { Conversation, Participant } from '../types/index.ts'

const props = defineProps<{
token: string,
Expand All @@ -44,7 +52,11 @@ const store = useStore()
const groupwareStore = useGroupwareStore()
const isMobile = useIsMobile()

// Add a visual bulk selection state for SelectableParticipant component
provide('bulkParticipantsSelection', true)

const isFormOpen = ref(false)
const isSelectorOpen = ref(false)
const loading = ref(Object.keys(groupwareStore.calendars).length === 0)
const submitting = ref(false)

Expand Down Expand Up @@ -95,6 +107,30 @@ const invalidHint = computed(() => {
}
})

const selectAll = ref(true)
const selectedAttendeeIds = ref<number[]>([])
const attendeeHint = computed(() => {
return selectedAttendeeIds.value?.length
? n('spreed', 'Sending %n invitation', 'Sending %n invitations', selectedAttendeeIds.value.length)
: t('spreed', 'Sending no invitations')
})

const searchText = ref('')
const isMatch = (string: string = '') => string.toLowerCase().includes(searchText.value.toLowerCase())

const participants = computed(() => {
const conversation: Conversation = store.getters.conversation(props.token)
return store.getters.participantsList(props.token).filter((participant: Participant) => {
return [ATTENDEE.ACTOR_TYPE.USERS, ATTENDEE.ACTOR_TYPE.EMAILS].includes(participant.actorType)
&& participant.attendeeId !== conversation.attendeeId
})
})
const filteredParticipants = computed(() => participants.value.filter((participant: Participant) => {
return isMatch(participant.displayName)
|| (participant.actorType === ATTENDEE.ACTOR_TYPE.USERS && isMatch(participant.actorId))
|| (participant.actorType === ATTENDEE.ACTOR_TYPE.EMAILS && isMatch(participant.invitedActorId))
}))

onBeforeMount(() => {
getCalendars()
})
Expand All @@ -110,13 +146,38 @@ watch(isFormOpen, (value) => {
selectedDateTimeEnd.value = new Date(moment().add(2, 'hours').startOf('hour'))
newMeetingTitle.value = ''
newMeetingDescription.value = ''
selectedAttendeeIds.value = participants.value.map((participant: Participant) => participant.attendeeId)
searchText.value = ''
selectAll.value = true
invalid.value = null
})

watch([selectedCalendar, selectedDateTimeStart, selectedDateTimeEnd], () => {
invalid.value = null
})

watch(participants, (value) => {
Antreesy marked this conversation as resolved.
Show resolved Hide resolved
if (selectAll.value) {
selectedAttendeeIds.value = value.map((participant: Participant) => participant.attendeeId)
}
})

/**
* Toggle selected attendees
* @param value switch value
*/
function toggleAll(value: boolean) {
selectedAttendeeIds.value = value ? participants.value.map((participant: Participant) => participant.attendeeId) : []
}

/**
* Check selected attendees
* @param value array of ids
*/
function checkSelection(value: number[]) {
selectAll.value = participants.value.length === value.length
}

/**
* Get user's calendars to identify belonging of known and future events
*/
Expand Down Expand Up @@ -151,7 +212,9 @@ async function submitNewMeeting() {
end: selectedDateTimeEnd.value.getTime() / 1000,
title: newMeetingTitle.value || null,
description: newMeetingDescription.value || null,
attendeeIds: selectAll.value ? null : selectedAttendeeIds.value,
})
showSuccess(t('spreed', 'Meeting created'))
isFormOpen.value = false
} catch (error) {
// @ts-expect-error Vue: Property response does not exist
Expand Down Expand Up @@ -232,7 +295,7 @@ async function submitNewMeeting() {
size="normal"
close-on-click-outside
:container="container">
<div class="calendar-meeting">
<div id="calendar-meeting" class="calendar-meeting">
<NcTextField v-model="newMeetingTitle"
:label="t('spreed', 'Meeting title')"
label-visible />
Expand Down Expand Up @@ -269,12 +332,21 @@ async function submitNewMeeting() {
{{ option.label }}
</template>
</NcSelect>
<p v-if="invalidHint" class="calendar-meeting__invalid-hint">
{{ invalidHint }}
</p>
<NcCheckboxRadioSwitch v-model="selectAll" type="switch" @update:modelValue="toggleAll">
{{ t('spreed', 'Invite all users and email guests') }}
</NcCheckboxRadioSwitch>
<div class="calendar-meeting__flex-wrapper">
<p>{{ attendeeHint }}</p>
<NcButton @click="isSelectorOpen = true">
{{ t('spreed', 'Select attendees') }}
</NcButton>
</div>
</div>

<template #actions>
<p v-if="invalidHint" class="calendar-meeting__invalid-hint">
{{ invalidHint }}
</p>
<NcButton type="primary"
:disabled="!selectedCalendar || submitting || !!invalid"
@click="submitNewMeeting">
Expand All @@ -286,6 +358,30 @@ async function submitNewMeeting() {
</NcButton>
</template>
</NcDialog>

<NcDialog v-if="canScheduleMeeting"
:open.sync="isSelectorOpen"
:name="t('spreed', 'Select attendees')"
close-on-click-outside
container="#calendar-meeting">
<SearchBox class="calendar-meeting__searchbox"
:value.sync="searchText"
is-focused
:placeholder-text="t('spreed', 'Search participants')"
@abort-search="searchText = ''" />
<ul v-if="filteredParticipants.length" class="calendar-meeting__attendees">
<SelectableParticipant v-for="participant in filteredParticipants"
:key="participant.attendeeId"
:checked.sync="selectedAttendeeIds"
:participant="participant"
@update:checked="checkSelection" />
</ul>
<NcEmptyContent v-else class="calendar-events__empty-content" :name="t('spreed', 'No results')">
<template #icon>
<IconAccountSearch />
</template>
</NcEmptyContent>
</NcDialog>
</div>
</template>

Expand All @@ -297,6 +393,10 @@ async function submitNewMeeting() {
padding-block-end: calc(var(--default-grid-baseline) * 3);
}

:deep(.dialog__actions) {
align-items: center;
}

&__list {
--item-height: calc(2lh + var(--default-grid-baseline) * 3);
display: flex;
Expand Down Expand Up @@ -358,6 +458,7 @@ async function submitNewMeeting() {
}

&__empty-content {
min-width: 150px;
margin-top: calc(var(--default-grid-baseline) * 3);
}

Expand All @@ -383,9 +484,22 @@ async function submitNewMeeting() {

&__flex-wrapper {
display: flex;
align-items: center;
gap: calc(var(--default-grid-baseline) * 2);
}

&__searchbox {
margin-inline: var(--default-grid-baseline);
margin-block-end: var(--default-grid-baseline);
width: calc(100% - var(--default-grid-baseline) * 2) !important;
}

&__attendees {
height: calc(100% - var(--default-clickable-area) - 2 * var(--default-grid-baseline));
padding-block: var(--default-grid-baseline);
overflow-y: auto;
}

// Overwrite default NcDateTimePickerNative styles
:deep(.native-datetime-picker) {
width: calc(50% - var(--default-grid-baseline));
Expand Down
3 changes: 2 additions & 1 deletion src/components/RightSidebar/Participants/ParticipantsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ export default {

return this.participants.filter(participant => {
return isMatch(participant.displayName)
|| (participant.actorType !== ATTENDEE.ACTOR_TYPE.GUESTS && isMatch(participant.actorId))
|| (![ATTENDEE.ACTOR_TYPE.GUESTS, ATTENDEE.ACTOR_TYPE.EMAILS].includes(participant.actorType) && isMatch(participant.actorId))
|| (participant.actorType === ATTENDEE.ACTOR_TYPE.EMAILS && isMatch(participant.invitedActorId))
})
},

Expand Down
4 changes: 3 additions & 1 deletion src/services/groupwareService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,17 @@ const getUserAbsence = async (userId: string): OutOfOfficeResponse => {
* @param payload.end Unix timestamp when the meeting ends, falls back to 60 minutes after start
* @param payload.title Title or summary of the event, falling back to the conversation name if none is given
* @param payload.description Description of the event, falling back to the conversation description if none is given
* @param payload.attendeeIds List of attendee ids to invite (null - everyone, [] - only actor)
* @param options options object destructured
*/
const scheduleMeeting = async function(token: string, { calendarUri, start, end, title, description }: scheduleMeetingParams, options?: object): scheduleMeetingResponse {
const scheduleMeeting = async function(token: string, { calendarUri, start, end, title, description, attendeeIds }: scheduleMeetingParams, options?: object): scheduleMeetingResponse {
return axios.post(generateOcsUrl('apps/spreed/api/v4/room/{token}/meeting', { token }, options), {
calendarUri,
start,
end,
title,
description,
attendeeIds,
} as scheduleMeetingParams, options)
}

Expand Down
Loading