add conference ui to calendar details page

refs CAL-4
flag=calendar_conferences

Test plan:
- enable FF "Add Conferences from Calendar"
  and "Allow Conference Selection..."
- enable BigBlueButton with dummy settings at
  /plugins/big_blue_button
- create two courses, one with multiple sections
- add LTI tool for conference selection to one course
- open add calendar event dialog at /calendar
- switch context to one of the courses
- verify that conferencing options appear (two options
  if in course with LTI; one option otherwise)
- click more options to open detailed edit page
- verify that conferencing options appear the same
  way
- add a conference
- save the conference
- verify that conference appears on the calendar
- repeat in the other context to verify other select UI
- add a conference before clicking "more options" and
  verify that it is included in the more options page
- verfy that updating the conference and then cancelling
  on the more options page does not save the update
- in the course with multiple sections, select "use a different
  date for each section"
- verify that the conference for the parent event is shown
  for each of the child events in the calendar
- verify that the conference can't be edited from the child events
- verify that updating the parent event conference from the More
  Details page also updates the child events (as seen in
  the calendar)

Change-Id: I9a7dccc9962d3c056f6a6a5fdb8a501ce8960c18
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/235298
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Ken McGrady <kmcgrady@instructure.com>
QA-Review: Steve Shepherd <sshepherd@instructure.com>
Product-Review: Michael Brewer-Davis <mbd@instructure.com>
This commit is contained in:
Michael Brewer-Davis 2020-04-24 11:55:53 -05:00 committed by Michael Brewer-Davis
parent f869f6b1f9
commit addd35f280
25 changed files with 584 additions and 111 deletions

View File

@ -71,12 +71,14 @@ export default class CalendarEvent extends Backbone.Model {
return result
}
fetch(otps = {}) {
fetch(opts = {}) {
let sectionsDfd, syncDfd
this.showSpinner()
const {success, error, ...options} = otps
const {success, error, ...options} = opts
options.url = this.url() + '?include[]=web_conference'
if (this.get('id')) {
syncDfd = (this.sync || Backbone.sync).call(this, 'read', this, options)
@ -128,6 +130,7 @@ export default class CalendarEvent extends Backbone.Model {
static mergeSectionsIntoCalendarEvent(eventData = {}, sections) {
eventData.recurring_calendar_events = ENV.RECURRING_CALENDAR_EVENTS_ENABLED
eventData.include_conference_selection = ENV.CALENDAR?.CONFERENCES_ENABLED
eventData.course_sections = sections
eventData.use_section_dates = !!(eventData.child_events && eventData.child_events.length)
_(eventData.child_events).each((child, index) => {

View File

@ -80,6 +80,10 @@ export default class EditCalendarEventDetails {
this.renderConferenceWidget()
}
canUpdateConference() {
return !this.event.lockedTitle
}
setConference = conference => {
this.conference = conference
setTimeout(this.renderConferenceWidget, 0)
@ -97,7 +101,8 @@ export default class EditCalendarEventDetails {
}
const conferenceNode = document.getElementById('calendar_event_conference_selection')
const activeConferenceTypes = this.getActiveConferenceTypes()
if (activeConferenceTypes.length === 0) {
const setConference = this.canUpdateConference() ? this.setConference : null
if (!this.conference && (!this.canUpdateConference() || activeConferenceTypes.length === 0)) {
this.conference = null
conferenceNode.closest('tr').className = 'hide'
} else {
@ -106,7 +111,7 @@ export default class EditCalendarEventDetails {
<CalendarConferenceWidget
context={this.currentContextInfo.asset_string}
conference={this.conference}
setConference={this.setConference}
setConference={setConference}
conferenceTypes={activeConferenceTypes}
/>,
conferenceNode
@ -189,7 +194,7 @@ export default class EditCalendarEventDetails {
if (data.end_time) params.end_time = data.end_time
if (data.duplicate) params.duplicate = data.duplicate
if (ENV.CALENDAR?.CONFERENCES_ENABLED) {
if (ENV.CALENDAR?.CONFERENCES_ENABLED && this.canUpdateConference()) {
if (this.conference) {
params.web_conference = this.conference
} else {
@ -228,7 +233,7 @@ export default class EditCalendarEventDetails {
}
this.$form.find('.more_options_link').attr('href', moreOptionsHref)
if (ENV.CALENDAR?.CONFERENCES_ENABLED) {
if (ENV.CALENDAR?.CONFERENCES_ENABLED && this.canUpdateConference()) {
// check conference is still valid in context
if (
this.conference &&
@ -286,7 +291,7 @@ export default class EditCalendarEventDetails {
'calendar_event[end_at]': data.end_at ? data.end_at.toISOString() : '',
'calendar_event[location_name]': location_name
}
if (ENV.CALENDAR?.CONFERENCES_ENABLED) {
if (ENV.CALENDAR?.CONFERENCES_ENABLED && this.canUpdateConference()) {
if (this.conference) {
const conferenceParams = new URLSearchParams(
$.param({calendar_event: {web_conference: this.conference}})

View File

@ -22,6 +22,9 @@ import I18n from 'i18n!calendar.edit'
import {showFlashAlert} from 'jsx/shared/FlashAlert'
import tz from 'timezone'
import Backbone from 'Backbone'
import React from 'react'
import ReactDOM from 'react-dom'
import 'jquery.instructure_forms'
import editCalendarEventFullTemplate from 'jst/calendar/editCalendarEventFull'
import MissingDateDialogView from '../views/calendar/MissingDateDialogView'
import RichContentEditor from 'jsx/shared/rce/RichContentEditor'
@ -30,6 +33,8 @@ import deparam from '../util/deparam'
import KeyboardShortcuts from '../views/editor/KeyboardShortcuts'
import coupleTimeFields from '../util/coupleTimeFields'
import datePickerFormat from 'jsx/shared/helpers/datePickerFormat'
import CalendarConferenceWidget from 'jsx/conferences/calendar/CalendarConferenceWidget'
import filterConferenceTypes from 'jsx/conferences/utils/filterConferenceTypes'
RichContentEditor.preloadRemoteModule()
@ -46,6 +51,7 @@ export default class EditCalendarEventView extends Backbone.View {
this.toggleUseSectionDates = this.toggleUseSectionDates.bind(this)
this.enableDuplicateFields = this.enableDuplicateFields.bind(this)
this.duplicateCheckboxChanged = this.duplicateCheckboxChanged.bind(this)
this.renderConferenceWidget = this.renderConferenceWidget.bind(this)
super.initialize(...arguments)
this.model.fetch().done(() => {
@ -59,7 +65,8 @@ export default class EditCalendarEventView extends Backbone.View {
'description',
'location_name',
'location_address',
'duplicate'
'duplicate',
'web_conference'
)
if (picked_params.start_at) {
picked_params.start_date = tz.format(
@ -115,7 +122,7 @@ export default class EditCalendarEventView extends Backbone.View {
datepicker: {dateFormat: datePickerFormat(I18n.t('#date.formats.default'))}
})
this.$('.time_field').time_field()
this.$('.date_start_end_row').each((_, row) => {
this.$('.date_start_end_row').each((_unused, row) => {
const date = $('.start_date', row).first()
const start = $('.start_time', row).first()
const end = $('.end_time', row).first()
@ -128,9 +135,44 @@ export default class EditCalendarEventView extends Backbone.View {
_.defer(this.attachKeyboardShortcuts)
_.defer(this.toggleDuplicateOptions)
_.defer(this.renderConferenceWidget)
return this
}
setConference = conference => {
this.model.set('web_conference', conference)
setTimeout(this.renderConferenceWidget, 0)
}
getActiveConferenceTypes() {
const conferenceTypes = ENV.conferences?.conference_types || []
const context = this.model.get('context_code')
return filterConferenceTypes(conferenceTypes, context)
}
renderConferenceWidget() {
if (!ENV.CALENDAR?.CONFERENCES_ENABLED) {
return
}
const conferenceNode = document.getElementById('calendar_event_conference_selection')
const activeConferenceTypes = this.getActiveConferenceTypes()
if (!this.model.get('web_conference') && activeConferenceTypes.length === 0) {
conferenceNode.closest('tr').className = 'hide'
} else {
conferenceNode.closest('tr').className = ''
ReactDOM.render(
<CalendarConferenceWidget
context={this.model.get('context_code')}
conference={this.model.get('web_conference')}
setConference={this.setConference}
conferenceTypes={activeConferenceTypes}
/>,
conferenceNode
)
}
}
attachKeyboardShortcuts() {
if (!ENV.use_rce_enhancements) {
return $('.switch_event_description_view')
@ -242,6 +284,15 @@ export default class EditCalendarEventView extends Backbone.View {
if (dialog.render()) return
}
if (ENV.CALENDAR?.CONFERENCES_ENABLED) {
const conference = this.model.get('web_conference')
if (conference) {
eventData.web_conference = conference
} else {
eventData.web_conference = ''
}
}
return this.saveEvent(eventData)
}
@ -249,7 +300,7 @@ export default class EditCalendarEventView extends Backbone.View {
return this.$el.disableWhileLoading(
this.model.save(eventData, {
success: () => this.redirectWithMessage(I18n.t('event_saved', 'Event Saved Successfully')),
error: (model, response, options) =>
error: (_model, response, _options) =>
showFlashAlert({
message: response.responseText,
err: null,
@ -305,7 +356,6 @@ export default class EditCalendarEventView extends Backbone.View {
append_iterator: this.$el.find('#append_iterator').is(':checked')
}
}
return data
}
@ -320,7 +370,7 @@ export default class EditCalendarEventView extends Backbone.View {
return this.$el.find('.duplicate_event_row').toggle(!disableValue)
}
duplicateCheckboxChanged(jsEvent, propagate) {
duplicateCheckboxChanged(jsEvent, _propagate) {
return this.enableDuplicateFields(jsEvent.target.checked)
}
}

View File

@ -16,7 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {within, fireEvent} from '@testing-library/dom'
import {within, fireEvent, getByText} from '@testing-library/dom'
import commonEventFactory from '../commonEventFactory'
import EditCalendarEventDetails from '../EditCalendarEventDetails'
@ -36,8 +36,17 @@ describe('EditCalendarEventDetails', () => {
window.ENV = null
})
function render() {
const event = commonEventFactory({calendar_event: {id: 1, context_code: 'course_1'}}, CONTEXTS)
function render(overrides = {}) {
const event = commonEventFactory(
{
calendar_event: {
id: 1,
context_code: 'course_1',
...overrides
}
},
CONTEXTS
)
event.allPossibleContexts = CONTEXTS
return new EditCalendarEventDetails(
@ -76,59 +85,98 @@ describe('EditCalendarEventDetails', () => {
expect(conferencingNode.closest('tr').className).not.toEqual('hide')
})
it('does not show conferencing options when context does not support conferences', () => {
enableConferences(CONFERENCE_TYPES.slice(1))
render()
const conferencingNode = within(document.body).getByText('Conferencing:')
expect(conferencingNode.closest('tr').className).toEqual('hide')
describe('when context does not support conferences', () => {
it('does not show conferencing options when there is no current conference', () => {
enableConferences(CONFERENCE_TYPES.slice(1))
render()
const conferencingRow = within(document.body)
.getByText('Conferencing:')
.closest('tr')
expect(conferencingRow.className).toEqual('hide')
})
it('does show current conference when there is a current conference', () => {
enableConferences(CONFERENCE_TYPES.slice(1))
render({web_conference: {id: 1, conference_type: 'type1', title: 'FooConf'}})
const conferencingRow = within(document.body)
.getByText('Conferencing:')
.closest('tr')
expect(conferencingRow.className).not.toEqual('hide')
expect(getByText(conferencingRow, 'FooConf')).not.toBeNull()
})
})
it('submits web_conference params for current conference', () => {
enableConferences()
const view = render()
view.conference = {
id: 1,
name: 'Foo',
conference_type: 'type1',
lti_settings: {a: 1, b: 2, c: 3}
}
view.event.save = jest.fn(params => {
;[
['calendar_event[web_conference][id]', '1'],
['calendar_event[web_conference][name]', 'Foo'],
['calendar_event[web_conference][conference_type]', 'type1'],
['calendar_event[web_conference][lti_settings][a]', '1'],
['calendar_event[web_conference][lti_settings][b]', '2'],
['calendar_event[web_conference][lti_settings][c]', '3']
].forEach(([key, value]) => {
expect(params[key]).toEqual(value)
describe('when event conference can be updated', () => {
it('submits web_conference params for current conference', () => {
enableConferences()
const view = render()
view.conference = {
id: 1,
name: 'Foo',
conference_type: 'type1',
lti_settings: {a: 1, b: 2, c: 3}
}
view.event.save = jest.fn(params => {
;[
['calendar_event[web_conference][id]', '1'],
['calendar_event[web_conference][name]', 'Foo'],
['calendar_event[web_conference][conference_type]', 'type1'],
['calendar_event[web_conference][lti_settings][a]', '1'],
['calendar_event[web_conference][lti_settings][b]', '2'],
['calendar_event[web_conference][lti_settings][c]', '3']
].forEach(([key, value]) => {
expect(params[key]).toEqual(value)
})
})
const submit = within(document.body).getByText('Submit')
fireEvent.click(submit)
expect(view.event.save).toHaveBeenCalled()
})
it('submits empty web_conference params when no current conference', () => {
enableConferences()
const view = render()
view.conference = null
view.event.save = jest.fn(params => {
expect(params['calendar_event[web_conference]']).toEqual('')
})
const submit = within(document.body).getByText('Submit')
fireEvent.click(submit)
expect(view.event.save).toHaveBeenCalled()
})
it('does not submit web_conference params when conferencing is disabled', () => {
const view = render()
view.event.save = jest.fn(params => {
expect(params['calendar_event[web_conference]']).toBeUndefined()
})
const submit = within(document.body).getByText('Submit')
fireEvent.click(submit)
expect(view.event.save).toHaveBeenCalled()
})
const submit = within(document.body).getByText('Submit')
fireEvent.click(submit)
expect(view.event.save).toHaveBeenCalled()
})
it('submits empty web_conference params when no current conference', () => {
enableConferences()
const view = render()
view.conference = null
view.event.save = jest.fn(params => {
expect(params['calendar_event[web_conference]']).toEqual('')
describe('when event conference cannot be updated', () => {
it('does not show conferencing options when there is no current conference', () => {
enableConferences()
render({parent_event_id: 1000})
const conferencingRow = within(document.body)
.getByText('Conferencing:')
.closest('tr')
expect(conferencingRow.className).toEqual('hide')
})
const submit = within(document.body).getByText('Submit')
fireEvent.click(submit)
expect(view.event.save).toHaveBeenCalled()
})
it('does not submit web_conference params when conferencing is disabled', () => {
const view = render()
view.event.save = jest.fn(params => {
expect(params['calendar_event[web_conference]']).toBeUndefined()
it('does not submit web_conference params', () => {
enableConferences()
const view = render({parent_event_id: 1000})
view.conference = null
view.event.save = jest.fn(params => {
expect(params['calendar_event[web_conference]']).toBeUndefined()
})
const submit = within(document.body).getByText('Submit')
fireEvent.click(submit)
expect(view.event.save).toHaveBeenCalled()
})
const submit = within(document.body).getByText('Submit')
fireEvent.click(submit)
expect(view.event.save).toHaveBeenCalled()
})
describe('when an event is moved between contexts', () => {

View File

@ -0,0 +1,145 @@
/*
* Copyright (C) 2020 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas 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, version 3 of the License.
*
* Canvas 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 'Backbone'
import _ from 'lodash'
import {within, getByText} from '@testing-library/dom'
import CalendarEvent from '../CalendarEvent'
import EditEventView from '../EditEventView'
jest.mock('jsx/shared/rce/RichContentEditor')
describe('EditEventView', () => {
beforeEach(() => {
window.ENV = {}
document.body.innerHTML = '<div id="application"><form id="content"></form></div>'
})
afterEach(() => {
window.ENV = null
})
function render(overrides = {}) {
const event = new CalendarEvent({
id: 1,
context_code: 'course_1',
...overrides
})
event.sync = () => {}
return new EditEventView({el: document.getElementById('content'), model: event})
}
async function waitForRender() {
let rendered
const promise = new Promise(resolve => {
rendered = resolve
})
_.defer(() => rendered())
await promise
}
it('renders', () => {
render()
expect(within(document.body).getByText('Edit Calendar Event')).not.toBeNull()
})
describe('conferences', () => {
const CONFERENCE_TYPES = [
{name: 'Type1', type: 'type1', contexts: ['course_1']},
{name: 'Type2', type: 'type2', contexts: ['course_2', 'course_3']}
]
function enableConferences(conference_types = CONFERENCE_TYPES) {
window.ENV.CALENDAR = {CONFERENCES_ENABLED: true}
window.ENV.conferences = {conference_types}
}
it('does not show conferencing options when calendar conferences are disabled', () => {
render()
expect(within(document.body).queryByText('Conferencing:')).toBeNull()
})
it('shows conferencing options when calendar conferences are enabled', () => {
enableConferences()
render()
const conferencingNode = within(document.body).getByText('Conferencing:')
expect(conferencingNode.closest('tr').className).not.toEqual('hide')
})
describe('when context does not support conferences', () => {
it('does not show conferencing options when there is no current conference', async () => {
enableConferences(CONFERENCE_TYPES.slice(1))
render()
await waitForRender()
const conferencingRow = within(document.body)
.getByText('Conferencing:')
.closest('tr')
expect(conferencingRow.className).toEqual('hide')
})
it('does show current conference when there is a current conference', async () => {
enableConferences(CONFERENCE_TYPES.slice(1))
render({web_conference: {id: 1, conference_type: 'type1', title: 'FooConf'}})
const conferencingRow = within(document.body)
.getByText('Conferencing:')
.closest('tr')
await waitForRender()
expect(conferencingRow.className).not.toEqual('hide')
expect(getByText(conferencingRow, 'FooConf')).not.toBeNull()
})
})
it('submits web_conference params for current conference', () => {
enableConferences()
const web_conference = {
id: '1',
name: 'Foo',
conference_type: 'type1',
lti_settings: {a: 1, b: 2, c: 3}
}
const view = render({
web_conference
})
view.model.save = jest.fn(params => {
expect(params.web_conference).toEqual(web_conference)
})
view.submit(null)
expect(view.model.save).toHaveBeenCalled()
})
it('submits empty web_conference params when no current conference', () => {
enableConferences()
const view = render()
view.model.save = jest.fn(params => {
expect(params.web_conference).toEqual('')
})
view.submit(null)
expect(view.model.save).toHaveBeenCalled()
})
it('does not submit web_conference params when conferencing is disabled', () => {
const view = render()
view.model.save = jest.fn(params => {
expect(params.web_conference).toBeUndefined()
})
view.submit(null)
expect(view.model.save).toHaveBeenCalled()
})
})
})

View File

@ -470,9 +470,11 @@ class CalendarEventsApiController < ApplicationController
params_for_create[:description] = process_incoming_html_content(params_for_create[:description])
end
if Account.site_admin.feature_enabled?(:calendar_conferences)
web_conference = find_or_initialize_conference(@context, params_for_create[:web_conference])
return unless authorize_user_for_conference(@current_user, web_conference)
params_for_create[:web_conference] = web_conference
if params_for_create.key?(:web_conference)
web_conference = find_or_initialize_conference(@context, params_for_create[:web_conference])
return unless authorize_user_for_conference(@current_user, web_conference)
params_for_create[:web_conference] = web_conference
end
end
@event = @context.calendar_events.build(params_for_create)
@ -657,9 +659,11 @@ class CalendarEventsApiController < ApplicationController
params_for_update[:description] = process_incoming_html_content(params_for_update[:description])
end
if Account.site_admin.feature_enabled?(:calendar_conferences)
web_conference = find_or_initialize_conference(@event.context, params_for_update[:web_conference])
return unless authorize_user_for_conference(@current_user, web_conference)
params_for_update[:web_conference] = web_conference
if params_for_update.key?(:web_conference)
web_conference = find_or_initialize_conference(@event.context, params_for_update[:web_conference])
return unless authorize_user_for_conference(@current_user, web_conference)
params_for_update[:web_conference] = web_conference
end
end
if @event.update(params_for_update)

View File

@ -17,12 +17,13 @@
#
class CalendarEventsController < ApplicationController
include CalendarConferencesHelper
before_action :require_context
before_action :rce_js_env, only: [:new, :edit]
add_crumb(proc { t(:'#crumbs.calendar_events', "Calendar Events")}, :only => [:show, :new, :edit]) { |c| c.send :calendar_url_for, c.instance_variable_get("@context") }
def show
@event = @context.calendar_events.find(params[:id])
add_crumb(@event.title, named_context_url(@context, :context_calendar_event_url, @event))
@ -44,19 +45,19 @@ class CalendarEventsController < ApplicationController
end
end
def new
@event = @context.calendar_events.temp_record
add_crumb(t('crumbs.new', "New Calendar Event"), named_context_url(@context, :new_context_calendar_event_url))
@event.assign_attributes(params.permit(:title, :start_at, :end_at, :location_name, :location_address))
@event.assign_attributes(permit_params(params, [:title, :start_at, :end_at, :location_name, :location_address, web_conference: strong_anything]))
js_env(:RECURRING_CALENDAR_EVENTS_ENABLED => feature_context.feature_enabled?(:recurring_calendar_events))
authorized_action(@event, @current_user, :create)
add_conference_types_to_js_env([@context])
authorized_action(@event, @current_user, :create) && authorize_user_for_conference(@current_user, @event.web_conference)
end
def create
params[:calendar_event][:time_zone_edited] = Time.zone.name if params[:calendar_event]
@event = @context.calendar_events.build(calendar_event_params)
if authorized_action(@event, @current_user, :create)
if authorized_action(@event, @current_user, :create) && authorize_user_for_conference(@current_user, @event.web_conference)
respond_to do |format|
@event.updating_user = @current_user
if @event.save
@ -73,10 +74,13 @@ class CalendarEventsController < ApplicationController
def edit
@event = @context.calendar_events.find(params[:id])
event_params = permit_params(params, [:title, :start_at, :end_at, :location_name, :location_address, web_conference: strong_anything])
return unless authorize_user_for_conference(@current_user, event_params[:web_conference])
if @event.grants_right?(@current_user, session, :update)
@event.update!(params.permit(:title, :start_at, :end_at, :location_name, :location_address))
@event.update!(event_params)
end
if authorized_action(@event, @current_user, :update_content)
add_conference_types_to_js_env([@context])
render :new
end
end
@ -85,9 +89,11 @@ class CalendarEventsController < ApplicationController
@event = @context.calendar_events.find(params[:id])
if authorized_action(@event, @current_user, :update)
respond_to do |format|
params[:calendar_event][:time_zone_edited] = Time.zone.name if params[:calendar_event]
params_for_update = calendar_event_params
params_for_update[:calendar_event][:time_zone_edited] = Time.zone.name if params_for_update[:calendar_event]
return unless authorize_user_for_conference(@current_user, params_for_update[:web_conference])
@event.updating_user = @current_user
if @event.update(calendar_event_params)
if @event.update(params_for_update)
log_asset_access(@event, "calendar", "calendar", 'participate')
flash[:notice] = t 'notices.updated', "Event was successfully updated."
format.html { redirect_to calendar_url_for(@context) }
@ -124,7 +130,19 @@ class CalendarEventsController < ApplicationController
end
def calendar_event_params
params.require(:calendar_event).
permit(CalendarEvent.permitted_attributes + [:child_event_data => strong_anything])
permit_params(
params.require(:calendar_event),
CalendarEvent.permitted_attributes + [:child_event_data => strong_anything, web_conference: strong_anything]
)
end
def permit_params(params, attrs)
params.permit(attrs).tap do |p|
if Account.site_admin.feature_enabled?(:calendar_conferences)
if p.key?(:web_conference)
p[:web_conference] = find_or_initialize_conference(@context, p[:web_conference])
end
end
end
end
end

View File

@ -17,14 +17,18 @@
#
module CalendarConferencesHelper
include Api::V1::Conferences
def find_or_initialize_conference(context, conference_params)
return nil if conference_params.blank?
valid_params = conference_params.slice(:title, :description, :conference_type, :lti_settings)
if conference_params[:id]
WebConference.find(conference_params[:id]).tap do |conf|
conf.context = context
conf.assign_attributes(valid_params)
if conf.grants_right?(@current_user, session, :update)
conf.context = context
conf.assign_attributes(valid_params)
end
end
elsif conference_params[:title].present?
context.web_conferences.build(valid_params.merge(user: @current_user))
@ -38,13 +42,15 @@ module CalendarConferencesHelper
elsif conference.changed?
authorized_action(conference, user, :update)
else
true
authorized_action(conference, user, :read)
end
end
def add_conference_types_to_js_env(contexts)
allowed_contexts = contexts.select {|c| c.grants_right?(@current_user, session, :create_conferences)}
type_to_contexts_map = {}
conference_types = contexts.flat_map do |context|
conference_types = allowed_contexts.flat_map do |context|
WebConference.conference_types(context).map do |type|
type_to_contexts_map[type] ||= []
type_to_contexts_map[type] << context
@ -56,8 +62,7 @@ module CalendarConferencesHelper
js_env(
conferences: {
conference_types: conference_types_json(conference_types),
root_context: @domain_root_account.asset_string
conference_types: conference_types_json(conference_types)
}
)
end

View File

@ -46,12 +46,12 @@ const AddConference = ({context, currentConferenceType, conferenceTypes, setConf
const onLtiContent = useCallback(
ltiContent => {
setRetrievingLTI(false)
const {title = '', text: description = '', ...ltiSettings} = ltiContent
const {title, text: description, ...ltiSettings} = ltiContent
ltiSettings.tool_id = selectedType?.lti_settings?.tool_id
setConference({
conference_type: 'LtiConference',
title: title || I18n.t('%{name} Conference', {name: selectedType.name}),
description,
description: description || '',
lti_settings: ltiSettings
})
},
@ -82,13 +82,15 @@ const AddConference = ({context, currentConferenceType, conferenceTypes, setConf
onSelect={onSelect}
/>
)}
<AddLtiConferenceDialog
context={context}
conferenceType={selectedType}
isOpen={isRetrievingLTI}
onRequestClose={onLtiClose}
onContent={onLtiContent}
/>
{isRetrievingLTI && (
<AddLtiConferenceDialog
context={context}
conferenceType={selectedType}
isOpen
onRequestClose={onLtiClose}
onContent={onLtiContent}
/>
)}
</View>
)
}

View File

@ -17,16 +17,22 @@
*/
import React from 'react'
import PropTypes from 'prop-types'
import {View} from '@instructure/ui-view'
import AddConference from './AddConference'
import Conference from './Conference'
import getConferenceType from '../utils/getConferenceType'
import webConference from 'jsx/shared/proptypes/webConference'
import webConferenceType from 'jsx/shared/proptypes/webConferenceType'
const CalendarConferenceWidget = ({context, conference, conferenceTypes, setConference}) => {
const currentConferenceType = conference && getConferenceType(conferenceTypes, conference)
const showAddConference =
setConference && (conferenceTypes.length > 1 || (conferenceTypes.length > 0 && !conference))
const removeConference = setConference ? () => setConference(null) : null
return (
<View as="div" padding="0 0 x-small">
{(!conference || conferenceTypes.length > 1) && (
{showAddConference && (
<AddConference
context={context}
currentConferenceType={currentConferenceType}
@ -45,7 +51,7 @@ const CalendarConferenceWidget = ({context, conference, conferenceTypes, setConf
<Conference
conference={conference}
conferenceType={currentConferenceType}
removeConference={() => setConference(null)}
removeConference={removeConference}
/>
</View>
)}
@ -53,4 +59,16 @@ const CalendarConferenceWidget = ({context, conference, conferenceTypes, setConf
)
}
CalendarConferenceWidget.propTypes = {
context: PropTypes.string.isRequired,
conference: webConference,
conferenceTypes: PropTypes.arrayOf(webConferenceType).isRequired,
setConference: PropTypes.func
}
CalendarConferenceWidget.defaultProps = {
conference: null,
setConference: null
}
export default CalendarConferenceWidget

View File

@ -17,6 +17,7 @@
*/
import React from 'react'
import PropTypes from 'prop-types'
import {CloseButton, IconButton} from '@instructure/ui-buttons'
import {Flex} from '@instructure/ui-flex'
import {IconXLine} from '@instructure/ui-icons'
@ -29,6 +30,7 @@ import {PresentationContent} from '@instructure/ui-a11y-content'
import sanitizeHtml from 'jsx/shared/sanitizeHtml'
import RichContentEditor from 'jsx/shared/rce/RichContentEditor'
import I18n from 'i18n!conferences'
import webConference from 'jsx/shared/proptypes/webConference'
// we use this to consolidate the import of tinymce into our environment
// (as recommended by jsx/shared/sanitizeHTML)
@ -100,4 +102,13 @@ const Conference = ({conference, removeConference}) =>
<LinkConference conference={conference} removeConference={removeConference} />
)
Conference.propTypes = {
conference: webConference.isRequired,
removeConference: PropTypes.func
}
Conference.defaultProps = {
removeConference: null
}
export default Conference

View File

@ -17,9 +17,15 @@
*/
import React from 'react'
import 'tinymce/tinymce'
import {render} from '@testing-library/react'
import CalendarConferenceWidget from '../CalendarConferenceWidget'
// we use RichContentEditor.preloadRemoteModule() to consolidate the import of
// tinymce in the code, but since dynamic loading takes time during tests, we do
// a static import here and mock out the dynamic
jest.mock('jsx/shared/rce/RichContentEditor')
describe('CalendarConferenceWidget', () => {
const conferenceTypes = [
{type: 'foo', name: 'Foo Conference', contexts: ['course_1', 'group_2']},
@ -59,4 +65,11 @@ describe('CalendarConferenceWidget', () => {
const {getByText} = render(<CalendarConferenceWidget {...makeParams()} />)
expect(getByText('Select Conference Provider')).not.toBeNull()
})
it('does not show a selector if setConference is not defined', () => {
const {queryByText} = render(
<CalendarConferenceWidget {...makeParams({setConference: null})} />
)
expect(queryByText('Select Conference Provider')).toBeNull()
})
})

View File

@ -23,7 +23,8 @@ const createConference = async (context, conferenceType) => {
const [contextType, contextId] = context.split('_')
const conferenceParams = {
conference_type: conferenceType.type,
title: I18n.t('%{name} Conference', {name: conferenceType.name})
title: I18n.t('%{name} Conference', {name: conferenceType.name}),
description: ''
}
const {json} = await doFetchApi({
path: `/api/v1/${contextType}s/${contextId}/conferences`,

View File

@ -16,10 +16,10 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {object, shape, string} from 'prop-types'
import {number, object, oneOfType, shape, string} from 'prop-types'
const webConference = shape({
id: string,
id: oneOfType([string, number]),
conference_type: string.isRequired,
context_id: string,
context_type: string,

View File

@ -20,6 +20,7 @@ const RichContentEditor = {
preloadRemoteModule() {},
loadNewEditor() {},
destroyRCE() {},
initSidebar() {},
callOnRCE(textarea, opName) {
if (opName === 'get_code') return textarea.innerHTML
}

View File

@ -305,7 +305,8 @@ class CalendarEvent < ActiveRecord::Base
:title,
:description,
:location_name,
:location_address
:location_address,
:web_conference
].freeze
LOCKED_ATTRIBUTES = CASCADED_ATTRIBUTES + [
:start_at,

View File

@ -21,7 +21,7 @@ class WebConference < ActiveRecord::Base
include TextHelper
attr_readonly :context_id, :context_type
belongs_to :context, polymorphic: [:course, :group, :account]
has_one :calendar_event, inverse_of: :web_conference
has_one :calendar_event, inverse_of: :web_conference, dependent: :nullify
has_many :web_conference_participants
has_many :users, :through => :web_conference_participants
has_many :invitees, -> { where(web_conference_participants: { participation_type: 'invitee' }) }, through: :web_conference_participants, source: :user

View File

@ -29,7 +29,7 @@
event_attrs[:sections_url] = context_url(@context, :api_v1_context_sections_url)
end
js_env :CALENDAR_EVENT => event_attrs
js_env CALENDAR: { CONFERENCES_ENABLED: Account.site_admin.feature_enabled?(:calendar_conferences) }
js_bundle :edit_calendar_event
css_bundle :tinymce, :edit_calendar_event_full
provide :right_side, render(:partial => 'shared/wiki_sidebar')

View File

@ -108,6 +108,14 @@
<input id="calendar_event_location_address" name="location_address" size="30" maxlength="255" type="text" value="{{location_address}}"/>
</td>
</tr>
{{#if include_conference_selection}}
<tr>
<td>
<label for="calendar_event_conference_selection">{{#t}}Conferencing:{{/t}}</label>
<div id="calendar_event_conference_selection"></div>
</td>
</tr>
{{/if}}
{{#if recurring_calendar_events}}
<tr class="duplicate_event_toggle_row hide">
<td style="vertical-align: top;"><label for="duplicate_event">{{#t "repeat"}}Duplicate{{/t}}</label> <input type="checkbox" id="duplicate_event" name="duplicate" value="true" style="margin-left: 10px;" />

View File

@ -27,7 +27,8 @@ module.exports = {
'^jsx/(.*)$': '<rootDir>/app/jsx/$1',
'^jst/(.*)$': '<rootDir>/app/views/jst/$1',
'^timezone$': '<rootDir>/public/javascripts/timezone_core.js',
'node_modules-version-of-backbone': require.resolve('backbone')
'node_modules-version-of-backbone': require.resolve('backbone'),
Backbone: '<rootDir>/public/javascripts/Backbone.js'
},
roots: ['app/jsx', 'app/coffeescripts', 'public/javascripts', 'gems/plugins'],
moduleDirectories: ['node_modules', 'public/javascripts', 'public/javascripts/vendor'],

View File

@ -23,7 +23,7 @@ module Lti::Messages
'assignment_selection' => %w(ltiResourceLink).freeze,
'homework_submission' => %w(file).freeze,
'link_selection' => %w(ltiResourceLink).freeze,
'conference_selection' => %w(ltiResourceLink).freeze
'conference_selection' => %w(link html).freeze
}.freeze
DOCUMENT_TARGETS = {
@ -47,7 +47,7 @@ module Lti::Messages
'assignment_selection' => %w(application/vnd.ims.lti.v1.ltilink).freeze,
'homework_submission' => %w(*/*).freeze,
'link_selection' => %w(application/vnd.ims.lti.v1.ltilink).freeze,
'conference_selection' => %w(application/vnd.ims.lti.v1.ltilink).freeze
'conference_selection' => %w(text/html */*).freeze
}.freeze
AUTO_CREATE = {

View File

@ -1424,6 +1424,18 @@ describe CalendarEventsApiController, type: :request do
})
expect(event.reload.web_conference).to be nil
end
it "should not remove a web conference if no argument provided" do
event = @course.calendar_events.create(title: 'to update', workflow_state: 'active', web_conference: conference)
api_call(:put, "/api/v1/calendar_events/#{event.id}", {
:controller => 'calendar_events_api', :action => 'update', :format => 'json', id: event.id
}, {
:calendar_event => {
location: 'foo'
}
})
expect(event.reload.web_conference_id).to eq conference.id
end
end
end
@ -2422,9 +2434,9 @@ describe CalendarEventsApiController, type: :request do
plugin = PluginSetting.create!(name: 'big_blue_button')
plugin.update_attribute(:settings, { key: 'value' })
3.times do |idx|
conference = WebConference.create(context: @course, user: @user, conference_type: 'BigBlueButton')
conference = WebConference.create!(context: @course, user: @user, conference_type: 'BigBlueButton')
conference.add_initiator(@user)
@course.calendar_events.create(title: "event #{idx}", workflow_state: 'active',
@course.calendar_events.create!(title: "event #{idx}", workflow_state: 'active',
web_conference: conference)
end
end

View File

@ -19,14 +19,55 @@
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
describe CalendarEventsController do
before :once do
course_with_teacher(active_all: true)
student_in_course(active_all: true)
course_event
def stub_conference_plugins
allow(WebConference).to receive(:plugins).and_return(
[web_conference_plugin_mock("big_blue_button", {:domain => "bbb.instructure.com", :secret_dec => "secret"})]
)
end
def course_event
@event = @course.calendar_events.create(:title => "some assignment")
let_once(:teacher_enrollment) { course_with_teacher(active_all: true) }
let_once(:course) { teacher_enrollment.course }
let_once(:student_enrollment) { student_in_course(course: course) }
let_once(:course_event) { course.calendar_events.create(:title => "some assignment") }
let_once(:other_teacher_enrollment) { course_with_teacher(active_all: true) }
before do
@course = course
@teacher = teacher_enrollment.user
@student = student_enrollment.user
@event = course_event
stub_conference_plugins
end
let(:conference_params) do
{ conference_type: 'BigBlueButton', title: 'a conference', user: teacher_enrollment.user }
end
let(:other_teacher_conference) { other_teacher_enrollment.course.web_conferences.create!(**conference_params, user: other_teacher_enrollment.user) }
shared_examples "accepts web_conference" do
before(:once) do
Account.site_admin.enable_feature! 'calendar_conferences'
end
it "accepts a new conference" do
user_session(@teacher)
make_request.call(conference_params)
expect(response.status).to be < 400
expect(get_event.call.web_conference).not_to be nil
end
it "accepts an existing conference" do
user_session(@teacher)
conference = @course.web_conferences.create!(conference_params)
make_request.call(id: conference.id, **conference_params)
expect(response.status).to be < 400
expect(get_event.call.web_conference_id).to eq conference.id
end
it "does not accept an existing conference the user doesn't have permission for" do
user_session(@teacher)
make_request.call(id: other_teacher_conference.id)
assert_unauthorized
end
end
describe "GET 'show'" do
@ -92,6 +133,27 @@ describe CalendarEventsController do
get 'new', params: {user_id: @teacher.id}
expect(@controller.js_env[:use_rce_enhancements]).to be(true)
end
context "with web conferences" do
before(:once) do
Account.site_admin.enable_feature! 'calendar_conferences'
end
it "includes conference environment" do
user_session(@teacher)
get 'new', params: {course_id: @course.id}
expect(@controller.js_env.dig(:conferences, :conference_types).length).to eq 1
end
include_examples 'accepts web_conference' do
let(:make_request) do
->(params) { get 'new', params: {course_id: @course.id, web_conference: params} }
end
let(:get_event) do
->{ @controller.instance_variable_get(:@event) }
end
end
end
end
describe "POST 'create'" do
@ -113,6 +175,15 @@ describe CalendarEventsController do
expect(assigns[:event]).not_to be_nil
expect(assigns[:event].title).to eql("some event")
end
include_examples 'accepts web_conference' do
let(:make_request) do
->(params) { post 'create', params: {course_id: @course.id, calendar_event: {title: 'some event', web_conference: params}} }
end
let(:get_event) do
->{ assigns[:event] }
end
end
end
describe "GET 'edit'" do
@ -126,6 +197,41 @@ describe CalendarEventsController do
get 'edit', params: {:course_id => @course.id, :id => @event.id}
assert_unauthorized
end
include_examples 'accepts web_conference' do
let(:make_request) do
->(params) { get 'edit', params: {course_id: @course.id, id: @event.id, web_conference: params} }
end
let(:get_event) do
->{ @event.reload }
end
end
# context "with web conferences" do
# before(:once) do
# Account.site_admin.enable_feature! 'calendar_conferences'
# end
# it "can update with a new conference" do
# user_session(@teacher)
# get 'edit', params: {course_id: @course.id, id: @event.id, web_conference: conference_params}
# expect(response).to be_successful
# expect(@event.reload.web_conference_id).not_to be nil
# end
# it "can update with an existing conference" do
# user_session(@teacher)
# conference = @course.web_conferences.create!(conference_params)
# get 'edit', params: {course_id: @course.id, id: @event.id, web_conference: {id: conference.id, **conference_params}}
# expect(@event.reload.web_conference_id).to eq conference.id
# end
# it "cannot create with an existing conference the user doesn't have permission for" do
# user_session(@teacher)
# get 'edit', params: {course_id: @course.id, id: @event.id, web_conference: {id: other_teacher_conference.id}}
# assert_unauthorized
# end
# end
end
describe "PUT 'update'" do
@ -148,6 +254,15 @@ describe CalendarEventsController do
expect(assigns[:event]).to eql(@event)
expect(assigns[:event].title).to eql("new title")
end
include_examples 'accepts web_conference' do
let(:make_request) do
->(params) { put 'update', params: {course_id: @course.id, id: @event.id, calendar_event: {web_conference: params}} }
end
let(:get_event) do
->{ assigns[:event] }
end
end
end
describe "DELETE 'destroy'" do

View File

@ -205,7 +205,8 @@ describe Lti::Messages::DeepLinkingRequest do
it 'sets the correct "accept_types"' do
expect(subject['accept_types']).to match_array %w(
ltiResourceLink
html
link
)
end
@ -218,7 +219,7 @@ describe Lti::Messages::DeepLinkingRequest do
it 'sets the correct "accept_media_types"' do
expect(subject['accept_media_types']).to eq(
'application/vnd.ims.lti.v1.ltilink'
'text/html,*/*'
)
end

View File

@ -310,6 +310,17 @@ describe WebConference do
end
end
context "calendar events" do
it "nullifies event conference when a conference is destroyed" do
course_with_teacher(active_all: true)
conference = WimbaConference.create!(title: "my conference", user: @user, context: @course)
event = calendar_event_model web_conference: conference
conference.destroy!
expect(event.reload.web_conference).to be nil
end
end
context "LTI conferences" do
let_once(:course) { course_model }
let_once(:tool) do