misc = require('smc-util/misc')
{defaults, required} = misc
{webapp_client} = require('../webapp_client')
{React, ReactDOM, rclass, rtypes} = require('../smc-react')
{Button, ButtonToolbar, ButtonGroup, FormGroup, FormControl, InputGroup, Row, Col, Panel, Well} = require('react-bootstrap')
{User} = require('../users')
{ErrorDisplay, Icon, MarkdownInput, SearchInput, Space, TimeAgo, Tip} = require('../r_misc')
{StudentAssignmentInfo, StudentAssignmentInfoHeader} = require('./common')
util = require('./util')
styles = require('./styles')
exports.StudentsPanel = rclass ({name}) ->
displayName: "CourseEditorStudents"
reduxProps:
"
expanded_students : rtypes.immutable.Set
active_student_sort : rtypes.object
get_student_name : rtypes.func
propTypes:
name : rtypes.string.isRequired
redux : rtypes.object.isRequired
project_id : rtypes.string.isRequired
students : rtypes.object.isRequired
user_map : rtypes.object.isRequired
project_map : rtypes.object.isRequired
assignments : rtypes.object.isRequired
getInitialState: ->
err : undefined
search : ''
add_search : ''
add_searching : false
add_select : undefined
existing_students: undefined
selected_option_nodes : undefined
show_deleted : false
do_add_search: (e) ->
e?.preventDefault()
if not @props.students?
return
if @state.add_searching
return
search = @state.add_search.trim()
if search.length == 0
@setState(err:undefined, add_select:undefined, existing_students:undefined, selected_option_nodes:undefined)
return
@setState(add_searching:true, add_select:undefined, existing_students:undefined, selected_option_nodes:undefined)
add_search = @state.add_search
webapp_client.user_search
query : add_search
limit : 50
cb : (err, select) =>
if err
@setState(add_searching:false, err:err, add_select:undefined, existing_students:undefined)
return
users = @props.redux.getStore('projects').get_users(@props.project_id)
already_added = users.toJS()
existing_students = {}
existing_students.account = {}
existing_students.email = {}
@props.students.map (val, key) =>
for n in ['account_id', 'email_address']
if val.get(n)?
already_added[val.get(n)] = true
exclude_add = (account_id, email_address) =>
aa = already_added[account_id] or already_added[email_address]
if aa
if account_id?
existing_students.account[account_id] = true
if email_address?
existing_students.email[email_address] = true
return aa
select2 = (x for x in select when not exclude_add(x.account_id, x.email_address))
select3 = (x for x in noncloud_emails(select, add_search) when not exclude_add(null, x.email_address)).concat(select2)
@setState(add_searching:false, add_select:select3, existing_students:existing_students)
student_add_button: ->
<Button onClick={@do_add_search}>
{if @props.add_searching then <Icon name="cc-icon-cocalc-ring" spin /> else <Icon name="search" />}
</Button>
add_selector_clicked: ->
@setState(selected_option_nodes: ReactDOM.findDOMNode(@refs.add_select).selectedOptions)
add_selected_students: (options) ->
emails = {}
for x in @state.add_select
if x.account_id?
emails[x.account_id] = x.email_address
students = []
selections = []
if (not @state.selected_option_nodes? or @state.selected_option_nodes?.length == 0) and options?.length == 1
selections.push(options[0].key)
else
for option in @state.selected_option_nodes
selections.push(option.getAttribute('value'))
for y in selections
if misc.is_valid_uuid_string(y)
students.push
account_id : y
email_address : emails[y]
else
students.push({email_address:y})
@actions(@props.name).add_students(students)
@setState(err:undefined, add_select:undefined, selected_option_nodes:undefined, add_search:'')
get_add_selector_options: ->
v = []
seen = {}
for x in @state.add_select
key = x.account_id ? x.email_address
if seen[key]
continue
seen[key] = true
student_name = if x.account_id? then x.first_name + ' ' + x.last_name else x.email_address
v.push(<option key={key} value={key} label={student_name}>{student_name}</option>)
return v
render_add_selector: ->
if not @state.add_select?
return
options = @get_add_selector_options()
<FormGroup>
<FormControl componentClass='select' multiple ref="add_select" rows=10 onClick={@add_selector_clicked}>
{options}
</FormControl>
{@render_add_selector_button(options)}
</FormGroup>
render_add_selector_button: (options) ->
nb_selected = @state.selected_option_nodes?.length ? 0
_ = require('underscore')
es = @state.existing_students
if es?
existing = _.keys(es.email).length + _.keys(es.account).length > 0
else
existing = 0
btn_text = switch options.length
when 0 then (if existing then "Student already added" else "No student found")
when 1 then "Add student"
else switch nb_selected
when 0 then "Select student above"
when 1 then "Add selected student"
else "Add #{nb_selected} students"
disabled = options.length == 0 or (options.length >= 2 and nb_selected == 0)
<Button onClick={=>@add_selected_students(options)} disabled={disabled}><Icon name='user-plus' /> {btn_text}</Button>
render_error: ->
ed = null
if @state.err
ed = <ErrorDisplay error={misc.trunc(@state.err,1024)} onClose={=>@setState(err:undefined)} />
else if @state.existing_students?
existing = []
for email, v of @state.existing_students.email
existing.push(email)
for account_id, v of @state.existing_students.account
user = @props.user_map.get(account_id)
existing.push("#{user.get('first_name')} #{user.get('last_name')}")
if existing.length > 0
if existing.length > 1
msg = "Already added students or project collaborators: "
else
msg = "Already added student or project collaborator: "
msg += existing.join(', ')
ed = <ErrorDisplay bsStyle='info' error=msg onClose={=>@setState(existing_students:undefined)} />
if ed?
<Row style={marginTop:'1em', marginBottom:'-10px'}><Col md=5 lgOffset=7>{ed}</Col></Row>
render_header: (num_omitted) ->
<div>
<Row style={marginBottom:'-15px'}>
<Col md=3>
<SearchInput
placeholder = "Find students..."
default_value = {@state.search}
on_change = {(value)=>@setState(search:value)}
/>
</Col>
<Col md=4>
{<h6>(Omitting {num_omitted} students)</h6> if num_omitted}
</Col>
<Col md=5>
<form onSubmit={@do_add_search}>
<FormGroup>
<InputGroup>
<FormControl
ref = 'student_add_input'
type = 'text'
placeholder = "Add student by name or email address..."
value = {@state.add_search}
onChange = {=>@setState(add_select:undefined, add_search:ReactDOM.findDOMNode(@refs.student_add_input).value)}
onKeyDown = {(e)=>if e.keyCode==27 then @setState(add_search:'', add_select:undefined)}
/>
<InputGroup.Button>
{@student_add_button()}
</InputGroup.Button>
</InputGroup>
</FormGroup>
</form>
{@render_add_selector()}
</Col>
</Row>
{@render_error()}
</div>
compute_student_list: ->
v = util.parse_students(@props.students, @props.user_map, @props.redux)
v.sort(util.pick_student_sorter(@props.active_student_sort))
if @props.active_student_sort.is_descending
v.reverse()
w = (x for x in v when x.deleted)
num_deleted = w.length
v = (x for x in v when not x.deleted)
if @state.show_deleted
v = v.concat(w)
num_omitted = 0
if @state.search
words = misc.split(@state.search.toLowerCase())
search = (a) -> ((a.last_name ? '') + (a.first_name ? '') + (a.email_address ? '')).toLowerCase()
match = (s) ->
for word in words
if s.indexOf(word) == -1
num_omitted += 1
return false
return true
v = (x for x in v when match(search(x)))
return {students:v, num_omitted:num_omitted, num_deleted:num_deleted}
render_sort_link: (column_name, display_name) ->
<a href=''
onClick={(e)=>e.preventDefault();@actions(@props.name).set_active_student_sort(column_name)}>
{display_name}
<Space/>
{<Icon style={marginRight:'10px'}
name={if @props.active_student_sort.is_descending then 'caret-up' else 'caret-down'}
/> if @props.active_student_sort.column_name == column_name}
</a>
render_student_table_header: ->
<Row style={marginTop:'-10px', marginBottom:'3px'}>
<Col md=3>
<div style={display:'inline-block', width:'50%'}>
{@render_sort_link("first_name", "First Name")}
</div>
<div style={display:"inline-block"}>
{@render_sort_link("last_name", "Last Name")}
</div>
</Col>
<Col md=2>
{@render_sort_link("email", "Student Email")}
</Col>
<Col md=4>
{@render_sort_link("last_active", "Last Active")}
</Col>
<Col md=3>
{@render_sort_link("hosting", "Hosting Type")}
</Col>
</Row>
render_students: (students) ->
for x,i in students
name =
full : @props.get_student_name(x.student_id)
first : x.first_name
last : x.last_name
<Student background={if i%2==0 then "#eee"} key={x.student_id}
student_id={x.student_id} student={@props.students.get(x.student_id)}
user_map={@props.user_map} redux={@props.redux} name={@props.name}
project_map={@props.project_map}
assignments={@props.assignments}
is_expanded={@props.expanded_students.has(x.student_id)}
student_name={name}
display_account_name={true}
/>
render_show_deleted: (num_deleted, shown_students) ->
if @state.show_deleted
<Button style={styles.show_hide_deleted(needs_margin : shown_students.length > 0)} onClick={=>@setState(show_deleted:false)}>
<Tip placement='left' title="Hide deleted" tip="Students are never really deleted. Click this button so that deleted students aren't included at the bottom of the list of students. Deleted students are always hidden from the list of grades.">
Hide {num_deleted} deleted students
</Tip>
</Button>
else
<Button style={styles.show_hide_deleted(needs_margin : shown_students.length > 0)} onClick={=>@setState(show_deleted:true,search:'')}>
<Tip placement='left' title="Show deleted" tip="Students are not deleted forever, even after you delete them. Click this button to show any deleted students at the bottom of the list. You can then click on the student and click undelete to bring the assignment back.">
Show {num_deleted} deleted students
</Tip>
</Button>
render: ->
{students, num_omitted, num_deleted} = @compute_student_list()
<Panel header={@render_header(num_omitted, num_deleted)}>
{@render_student_table_header() if students.length > 0}
{@render_students(students)}
{@render_show_deleted(num_deleted, students) if num_deleted}
</Panel>
exports.StudentsPanel.Header = rclass
propTypes:
n : rtypes.number
render: ->
<Tip delayShow=1300
title="Students"
tip="This tab lists all students in your course, along with their grades on each assignment. You can also quickly find students by name on the left and add new students on the right.">
<span>
<Icon name="users"/> Students {if @props?.n? then " (#{@props.n})" else ""}
</span>
</Tip>
Student = rclass
displayName: "CourseEditorStudent"
propTypes:
redux : rtypes.object.isRequired
name : rtypes.string.isRequired
student : rtypes.object.isRequired
user_map : rtypes.object.isRequired
project_map : rtypes.object.isRequired
assignments : rtypes.object.isRequired
background : rtypes.string
is_expanded : rtypes.bool
student_name : rtypes.object
display_account_name : rtypes.bool
shouldComponentUpdate: (nextProps, nextState) ->
return @state != nextState or @props.student != nextProps.student or @props.assignments != nextProps.assignments or @props.project_map != nextProps.project_map or @props.user_map != nextProps.user_map or @props.background != nextProps.background or @props.is_expanded != nextProps.is_expanded or @props.student_name.full != nextProps.student_name.full
componentWillReceiveProps: (next) ->
if @props.student_name.first != next.student_name.first
@setState(edited_first_name : next.student_name.first)
if @props.student_name.last != next.student_name.last
@setState(edited_last_name : next.student_name.last)
if @props.student.get('email_address') != next.student.get('email_address')
@setState(edited_email_address : next.student.get('email_address'))
getInitialState: ->
confirm_delete : false
editing_student : false
edited_first_name : @props.student_name.first ? ""
edited_last_name : @props.student_name.last ? ""
edited_email_address : @props.student.get('email_address') ? ""
on_key_down: (e) ->
switch e.keyCode
when 13
@save_student_changes()
when 27
@cancel_student_edit()
toggle_show_more: (e) ->
e.preventDefault()
if @state.editing_student
@cancel_student_edit()
item_id = @props.student.get('student_id')
@actions(@props.name).toggle_item_expansion('student', item_id)
render_student: ->
<a href='' onClick={@toggle_show_more}>
<Icon style={marginRight:'10px'}
name={if @props.is_expanded then 'caret-down' else 'caret-right'}
/>
{@render_student_name()}
</a>
render_student_name: ->
account_id = @props.student.get('account_id')
if account_id?
return <User account_id={account_id} user_map={@props.user_map} name={@props.student_name.full} show_original={@props.display_account_name}/>
return <span>{@props.student.get("email_address")} (invited)</span>
render_student_email: ->
email = @props.student.get("email_address")
return <a href="mailto:#{email}">{email}</a>
open_project: ->
@actions('projects').open_project(project_id:@props.student.get('project_id'))
create_project: ->
@actions(@props.name).create_student_project(@props.student_id)
render_last_active: ->
student_project_id = @props.student.get('project_id')
if not student_project_id?
return
last_active = @props.redux.getStore('projects').get_last_active(student_project_id)?.get(@props.student.get('account_id'))
if last_active
return <span style={color:"#666"}>(last used project <TimeAgo date={last_active} />)</span>
else
return <span style={color:"#666"}>(has never used project)</span>
render_hosting: ->
student_project_id = @props.student.get('project_id')
if student_project_id
upgrades = @props.redux.getStore('projects').get_total_project_quotas(student_project_id)
if not upgrades?
return
if upgrades.member_host
<Tip placement='left' title={<span><Icon name='check'/> Members-only hosting</span>} tip='Projects is on a members-only server, which is much more robust and has priority support.'>
<span style={color:'#888', cursor:'pointer'}><Icon name='check'/> Members-only</span>
</Tip>
else
<Tip placement='left' title={<span><Icon name='exclamation-triangle'/> Free hosting</span>} tip='Project is hosted on a free server, so it may be overloaded and will be rebooted frequently. Please upgrade in course settings.'>
<span style={color:'#888', cursor:'pointer'}><Icon name='exclamation-triangle'/> Free</span>
</Tip>
render_project_access: ->
create = @props.student.get("create_project")
if create?
how_long = (webapp_client.server_time() - create)/1000
if how_long < 120
return <div><Icon name="cc-icon-cocalc-ring" spin /> Creating project... (started <TimeAgo date={create} />)</div>
student_project_id = @props.student.get('project_id')
if student_project_id?
<ButtonToolbar>
<ButtonGroup>
<Button onClick={@open_project}>
<Tip placement='right'
title='Student project'
tip='Open the course project for this student.'
>
<Icon name="edit" /> Open student project
</Tip>
</Button>
</ButtonGroup>
{@render_edit_student() if @props.student.get('account_id')}
</ButtonToolbar>
else
<Tip placement='right'
title='Create the student project'
tip='Create a new project for this student, then add (or invite) the student as a collaborator, and also add any collaborators on the project containing this course.'>
<Button onClick={@create_project}>
<Icon name="plus-circle" /> Create student project
</Button>
</Tip>
student_changed: ->
@props.student_name.first != @state.edited_first_name or
@props.student_name.last != @state.edited_last_name or
@props.student.get('email_address') != @state.edited_email_address
render_edit_student: ->
if @state.editing_student
disable_save = not @student_changed()
<ButtonGroup>
<Button onClick={@save_student_changes} bsStyle='success' disabled={disable_save}>
<Icon name='save'/> Save
</Button>
<Button onClick={@cancel_student_edit} >
Cancel
</Button>
</ButtonGroup>
else
<Button onClick={@show_edit_name_dialogue}>
<Icon name='address-card-o'/> Edit student...
</Button>
cancel_student_edit: ->
@setState(@getInitialState())
save_student_changes: ->
@actions(@props.name).set_internal_student_info @props.student,
first_name : @state.edited_first_name
last_name : @state.edited_last_name
email_address : @state.edited_email_address
@setState(editing_student:false)
show_edit_name_dialogue: ->
@setState(editing_student:true)
delete_student: ->
@actions(@props.name).delete_student(@props.student)
@setState(confirm_delete:false)
undelete_student: ->
@actions(@props.name).undelete_student(@props.student)
render_confirm_delete: ->
if @state.confirm_delete
<div>
Are you sure you want to delete this student (you can always undelete them later)?<Space/>
<ButtonToolbar>
<Button onClick={@delete_student} bsStyle='danger'>
<Icon name="trash" /> YES, Delete
</Button>
<Button onClick={=>@setState(confirm_delete:false)}>
Cancel
</Button>
</ButtonToolbar>
</div>
render_delete_button: ->
if not @props.is_expanded
return
if @state.confirm_delete
return @render_confirm_delete()
if @props.student.get('deleted')
<Button onClick={@undelete_student} style={float:'right'}>
<Icon name="trash-o" /> Undelete
</Button>
else
<Button onClick={=>@setState(confirm_delete:true)} style={float:'right'}>
<Icon name="trash" /> Delete...
</Button>
render_title_due: (assignment) ->
date = assignment.get('due_date')
if date
<span>(Due <TimeAgo date={date} />)</span>
render_title: (assignment) ->
<span>
<em>{misc.trunc_middle(assignment.get('path'), 50)}</em> {@render_title_due(assignment)}
</span>
render_assignments_info_rows: ->
store = @props.redux.getStore(@props.name)
for assignment in store.get_sorted_assignments()
grade = store.get_grade(assignment, @props.student)
info = store.student_assignment_info(@props.student, assignment)
<StudentAssignmentInfo
key={assignment.get('assignment_id')}
title={@render_title(assignment)}
name={@props.name}
student={@props.student}
assignment={assignment}
grade={grade}
info={info}
/>
render_assignments_info: ->
peer_grade = @props.redux.getStore(@props.name).any_assignment_uses_peer_grading()
header = <StudentAssignmentInfoHeader key='header' title="Assignment" peer_grade={peer_grade}/>
return [header, @render_assignments_info_rows()]
render_note: ->
<Row key='note' style={styles.note}>
<Col xs=2>
<Tip title="Notes about this student" tip="Record notes about this student here. These notes are only visible to you, not to the student. In particular, you might want to include an email address or other identifying information here, and notes about late assignments, excuses, etc.">
Notes
</Tip>
</Col>
<Col xs=10>
<MarkdownInput
persist_id = {@props.student.get('student_id') + "note"}
attach_to = {@props.name}
rows = 6
placeholder = 'Notes about student (not visible to student)'
default_value = {@props.student.get('note')}
on_save = {(value)=>@actions(@props.name).set_student_note(@props.student, value)}
/>
</Col>
</Row>
render_more_info: ->
v = []
v.push <Row key='more'>
<Col md=12>
{@render_assignments_info()}
</Col>
</Row>
v.push(@render_note())
return v
render_basic_info: ->
<Row key='basic' style={backgroundColor:@props.background}>
<Col md=3>
<h6>
{@render_student()}
{@render_deleted()}
</h6>
</Col>
<Col md=2>
<h6 style={color:"#666"}>
{@render_student_email()}
</h6>
</Col>
<Col md=4 style={paddingTop:'10px'}>
{@render_last_active()}
</Col>
<Col md=3 style={paddingTop:'10px'}>
{@render_hosting()}
</Col>
</Row>
render_deleted: ->
if @props.student.get('deleted')
<b> (deleted)</b>
render_panel_header: ->
<div>
<Row>
<Col md=8>
{@render_project_access()}
</Col>
<Col md=4>
{@render_delete_button()}
</Col>
</Row>
{<Row>
<Col md=4>
{@render_edit_student_interface()}
</Col>
</Row> if @state.editing_student }
</div>
render_edit_student_interface: ->
<Well style={marginTop:'10px'}>
<Row>
<Col md=6>
First Name
<FormGroup>
<FormControl
type = 'text'
autoFocus = {true}
value = {@state.edited_first_name}
onClick = {(e) => e.stopPropagation(); e.preventDefault()}
onChange = {(e) => @setState(edited_first_name : e.target.value)}
onKeyDown = {@on_key_down}
/>
</FormGroup>
</Col>
<Col md=6>
Last Name
<FormGroup>
<FormControl
type = 'text'
value = {@state.edited_last_name}
onClick = {(e) => e.stopPropagation(); e.preventDefault()}
onChange = {(e) => @setState(edited_last_name : e.target.value)}
onKeyDown = {@on_key_down}
/>
</FormGroup>
</Col>
</Row>
<Row>
<Col md=12>
Email Address
<FormGroup>
<FormControl
type = 'text'
value = {@state.edited_email_address}
onClick = {(e) => e.stopPropagation(); e.preventDefault()}
onChange = {(e) => @setState(edited_email_address : e.target.value)}
onKeyDown = {@on_key_down}
/>
</FormGroup>
</Col>
</Row>
</Well>
render_more_panel: ->
<Row>
<Panel header={@render_panel_header()}>
{@render_more_info()}
</Panel>
</Row>
render: ->
<Row style={if @state.more then styles.selected_entry_style}>
<Col xs=12>
{@render_basic_info()}
{@render_more_panel() if @props.is_expanded}
</Col>
</Row>
immutable_to_list = (x, primary_key) ->
if not x?
return
v = []
x.map (val, key) ->
v.push(misc.merge(val.toJS(), {"
return v
noncloud_emails = (v, s) ->
{string_queries, email_queries} = misc.parse_user_search(s)
result_emails = misc.dict(([r.email_address, true] for r in v when r.email_address?))
return ({email_address:r} for r in email_queries when not result_emails[r]).sort (a,b)->
misc.cmp(a.email_address,b.email_address)