Contact
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
| Download
Views: 39598
1
###############################################################################
2
#
3
# CoCalc: Collaborative Calculation in the Cloud
4
#
5
# Copyright (C) 2016 -- 2017, Sagemath Inc.
6
#
7
# This program is free software: you can redistribute it and/or modify
8
# it under the terms of the GNU General Public License as published by
9
# the Free Software Foundation, either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# This program is distributed in the hope that it will be useful,
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program. If not, see <http://www.gnu.org/licenses/>.
18
#
19
###############################################################################
20
21
underscore = require('underscore')
22
23
# CoCalc libraries
24
misc = require('smc-util/misc')
25
{defaults, required} = misc
26
{webapp_client} = require('../webapp_client')
27
28
# React libraries
29
{React, rclass, rtypes, Actions, ReactDOM} = require('../smc-react')
30
31
{Button, ButtonToolbar, ButtonGroup, FormControl, FormGroup, InputGroup, Row, Col} = require('react-bootstrap')
32
33
{ErrorDisplay, Icon, Space, TimeAgo, Tip, SearchInput} = require('../r_misc')
34
35
immutable = require('immutable')
36
37
exports.BigTime = BigTime = rclass
38
displayName : "CourseEditor-BigTime"
39
40
render: ->
41
date = @props.date
42
if not date?
43
return
44
if typeof(date) == 'string'
45
date = misc.ISO_to_Date(date)
46
return <TimeAgo popover={true} date={date} />
47
48
exports.StudentAssignmentInfoHeader = rclass
49
displayName : "CourseEditor-StudentAssignmentInfoHeader"
50
51
propTypes :
52
title : rtypes.string.isRequired
53
peer_grade : rtypes.bool
54
55
render_col: (number, key, width) ->
56
switch key
57
when 'last_assignment'
58
title = 'Assign to Student'
59
tip = 'This column gives the status of making homework available to students, and lets you copy homework to one student at a time.'
60
when 'collect'
61
title = 'Collect from Student'
62
tip = 'This column gives status information about collecting homework from students, and lets you collect from one student at a time.'
63
when 'grade'
64
title = 'Grade'
65
tip = 'Record homework grade" tip="Use this column to record the grade the student received on the assignment. Once the grade is recorded, you can return the assignment. You can also export grades to a file in the Settings tab.'
66
67
when 'peer-assign'
68
title = 'Assign Peer Grading'
69
tip = 'This column gives the status of sending out collected homework to students for peer grading.'
70
71
when 'peer-collect'
72
title = 'Collect Peer Grading'
73
tip = 'This column gives status information about collecting the peer grading work that students did, and lets you collect peer grading from one student at a time.'
74
75
when 'return_graded'
76
title = 'Return to Student'
77
tip = 'This column gives status information about when you returned homework to the students. Once you have entered a grade, you can return the assignment.'
78
placement = 'left'
79
<Col md={width} key={key}>
80
<Tip title={title} tip={tip}>
81
<b>{number}. {title}</b>
82
</Tip>
83
</Col>
84
85
86
render_headers: ->
87
w = 3
88
<Row>
89
{@render_col(1, 'last_assignment', w)}
90
{@render_col(2, 'collect', w)}
91
{@render_col(3, 'grade', w)}
92
{@render_col(4, 'return_graded', w)}
93
</Row>
94
95
render_headers_peer: ->
96
w = 2
97
<Row>
98
{@render_col(1, 'last_assignment', w)}
99
{@render_col(2, 'collect', w)}
100
{@render_col(3, 'peer-assign', w)}
101
{@render_col(4, 'peer-collect', w)}
102
{@render_col(5, 'grade', w)}
103
{@render_col(6, 'return_graded', w)}
104
</Row>
105
106
render: ->
107
<Row style={borderBottom:'2px solid #aaa'} >
108
<Col md=2 key='title'>
109
<Tip title={@props.title} tip={if @props.title=="Assignment" then "This column gives the directory name of the assignment." else "This column gives the name of the student."}>
110
<b>{@props.title}</b>
111
</Tip>
112
</Col>
113
<Col md=10 key="rest">
114
{if @props.peer_grade then @render_headers_peer() else @render_headers()}
115
</Col>
116
</Row>
117
118
exports.StudentAssignmentInfo = rclass
119
displayName : "CourseEditor-StudentAssignmentInfo"
120
121
propTypes :
122
name : rtypes.string.isRequired
123
title : rtypes.oneOfType([rtypes.string,rtypes.object]).isRequired
124
student : rtypes.oneOfType([rtypes.string,rtypes.object]).isRequired # required string (student_id) or student immutable js object
125
assignment : rtypes.oneOfType([rtypes.string,rtypes.object]).isRequired # required string (assignment_id) or assignment immutable js object
126
grade : rtypes.string
127
info : rtypes.object.isRequired
128
129
getInitialState: ->
130
editing_grade : false
131
edited_grade : ''
132
133
open: (type, assignment_id, student_id) ->
134
@actions(@props.name).open_assignment(type, assignment_id, student_id)
135
136
copy: (type, assignment_id, student_id) ->
137
@actions(@props.name).copy_assignment(type, assignment_id, student_id)
138
139
stop: (type, assignment_id, student_id) ->
140
@actions(@props.name).stop_copying_assignment(type, assignment_id, student_id)
141
142
save_grade: (e) ->
143
e?.preventDefault()
144
@actions(@props.name).set_grade(@props.assignment, @props.student, @state.edited_grade)
145
@setState(editing_grade:false)
146
147
edit_grade: ->
148
@setState(edited_grade:@props.grade ? '', editing_grade:true)
149
150
render_grade_score: ->
151
if @state.editing_grade
152
<form key='grade' onSubmit={@save_grade} style={marginTop:'15px'}>
153
<FormGroup>
154
<InputGroup>
155
<FormControl
156
autoFocus
157
value = {@state.edited_grade}
158
ref = 'grade_input'
159
type = 'text'
160
placeholder = 'Grade (any text)...'
161
onChange = {=>@setState(edited_grade:ReactDOM.findDOMNode(@refs.grade_input).value ? '')}
162
onBlur = {@save_grade}
163
onKeyDown = {(e)=>if e.keyCode == 27 then @setState(edited_grade:@props.grade, editing_grade:false)}
164
/>
165
<InputGroup.Button>
166
<Button bsStyle='success'>Save</Button>
167
</InputGroup.Button>
168
</InputGroup>
169
</FormGroup>
170
</form>
171
else
172
if @props.grade
173
<div key='grade' onClick={@edit_grade}>
174
Grade: {@props.grade}
175
</div>
176
177
render_grade: (width) ->
178
bsStyle = if not (@props.grade ? '').trim() then 'primary'
179
<Col md={width} key='grade'>
180
<Tip title="Enter student's grade" tip="Enter the grade that you assigned to your student on this assignment here. You can enter anything (it doesn't have to be a number).">
181
<Button key='edit' onClick={@edit_grade} bsStyle={bsStyle}>Enter grade</Button>
182
</Tip>
183
{@render_grade_score()}
184
</Col>
185
186
render_last_time: (name, time) ->
187
<div key='time' style={color:"#666"}>
188
(<BigTime date={time} />)
189
</div>
190
191
render_open_recopy_confirm: (name, open, copy, copy_tip, open_tip, placement) ->
192
key = "recopy_#{name}"
193
if @state[key]
194
v = []
195
v.push <Button key="copy_confirm" bsStyle="danger" onClick={=>@setState("#{key}":false);copy()}>
196
<Icon name="share-square-o" rotate={"180" if name.indexOf('ollect')!=-1}/> Yes, {name.toLowerCase()} again
197
</Button>
198
v.push <Button key="copy_cancel" onClick={=>@setState("#{key}":false);}>
199
Cancel
200
</Button>
201
return v
202
else
203
<Button key="copy" bsStyle='warning' onClick={=>@setState("#{key}":true)}>
204
<Tip title={name} placement={placement}
205
tip={<span>{copy_tip}</span>}>
206
<Icon name='share-square-o' rotate={"180" if name.indexOf('ollect')!=-1}/> {name}...
207
</Tip>
208
</Button>
209
210
render_open_recopy: (name, open, copy, copy_tip, open_tip) ->
211
placement = if name == 'Return' then 'left' else 'right'
212
<ButtonToolbar key='open_recopy'>
213
{@render_open_recopy_confirm(name, open, copy, copy_tip, open_tip, placement)}
214
<Button key='open' onClick={open}>
215
<Tip title="Open assignment" placement={placement} tip={open_tip}>
216
<Icon name="folder-open-o" /> Open
217
</Tip>
218
</Button>
219
</ButtonToolbar>
220
221
render_open_copying: (name, open, stop) ->
222
if name == "Return"
223
placement = 'left'
224
<ButtonGroup key='open_copying'>
225
<Button key="copy" bsStyle='success' disabled={true}>
226
<Icon name="cc-icon-cocalc-ring" spin /> {name}ing
227
</Button>
228
<Button key="stop" bsStyle='danger' onClick={stop}>
229
<Icon name="times" />
230
</Button>
231
<Button key='open' onClick={open}>
232
<Icon name="folder-open-o" /> Open
233
</Button>
234
</ButtonGroup>
235
236
render_copy: (name, copy, copy_tip) ->
237
if name == "Return"
238
placement = 'left'
239
<Tip key="copy" title={name} tip={copy_tip} placement={placement} >
240
<Button onClick={copy} bsStyle={'primary'}>
241
<Icon name="share-square-o" rotate={"180" if name.indexOf('ollect')!=-1}/> {name}
242
</Button>
243
</Tip>
244
245
render_error: (name, error) ->
246
if typeof(error) != 'string'
247
error = misc.to_json(error)
248
if error.indexOf('No such file or directory') != -1
249
error = 'Somebody may have moved the folder that should have contained the assignment.\n' + error
250
else
251
error = "Try to #{name.toLowerCase()} again:\n" + error
252
<ErrorDisplay key='error' error={error} style={maxHeight: '140px', overflow:'auto'}/>
253
254
render_last: (name, obj, type, enable_copy, copy_tip, open_tip) ->
255
open = => @open(type, @props.info.assignment_id, @props.info.student_id)
256
copy = => @copy(type, @props.info.assignment_id, @props.info.student_id)
257
stop = => @stop(type, @props.info.assignment_id, @props.info.student_id)
258
obj ?= {}
259
v = []
260
if enable_copy
261
if obj.start
262
v.push(@render_open_copying(name, open, stop))
263
else if obj.time
264
v.push(@render_open_recopy(name, open, copy, copy_tip, open_tip))
265
else
266
v.push(@render_copy(name, copy, copy_tip))
267
if obj.time
268
v.push(@render_last_time(name, obj.time))
269
if obj.error
270
v.push(@render_error(name, obj.error))
271
return v
272
273
render_peer_assign: ->
274
<Col md={2} key='peer-assign'>
275
{@render_last('Peer Assign', @props.info.last_peer_assignment, 'peer-assigned', @props.info.last_collect?,
276
"Copy collected assignments from your project to this student's project so they can grade them.",
277
"Open the student's copies of this assignment directly in their project, so you can see what they are peer grading.")}
278
</Col>
279
280
render_peer_collect: ->
281
<Col md={2} key='peer-collect'>
282
{@render_last('Peer Collect', @props.info.last_peer_collect, 'peer-collected', @props.info.last_peer_assignment?,
283
"Copy the peer-graded assignments from various student projects back to your project so you can assign their official grade.",
284
"Open your copy of your student's peer grading work in your own project, so that you can grade their work.")}
285
</Col>
286
287
render: ->
288
peer_grade = @props.assignment.get('peer_grade')?.get('enabled')
289
show_grade_col = (peer_grade and @props.info.last_peer_collect) or (not peer_grade and @props.info.last_collect)
290
width = if peer_grade then 2 else 3
291
<Row style={borderTop:'1px solid #aaa', paddingTop:'5px', paddingBottom: '5px'}>
292
<Col md=2 key="title">
293
{@props.title}
294
</Col>
295
<Col md=10 key="rest">
296
<Row>
297
<Col md={width} key='last_assignment'>
298
{@render_last('Assign', @props.info.last_assignment, 'assigned', true,
299
"Copy the assignment from your project to this student's project so they can do their homework.",
300
"Open the student's copy of this assignment directly in their project. You will be able to see them type, chat with them, leave them hints, etc.")}
301
</Col>
302
<Col md={width} key='collect'>
303
{@render_last('Collect', @props.info.last_collect, 'collected', @props.info.last_assignment?,
304
"Copy the assignment from your student's project back to your project so you can grade their work.",
305
"Open the copy of your student's work in your own project, so that you can grade their work.")}
306
</Col>
307
{@render_peer_assign() if peer_grade and @props.info.peer_assignment}
308
{@render_peer_collect() if peer_grade and @props.info.peer_collect}
309
{if show_grade_col then @render_grade(width) else <Col md={width} key='grade'></Col>}
310
<Col md={width} key='return_graded'>
311
{@render_last('Return', @props.info.last_return_graded, 'graded', @props.info.last_collect?,
312
"Copy the graded assignment back to your student's project.",
313
"Open the copy of your student's work that you returned to them. This opens the returned assignment directly in their project.") if @props.grade}
314
</Col>
315
</Row>
316
</Col>
317
</Row>
318
319
# Multiple result selector
320
# use on_change and search to control the search bar
321
# Coupled with Assignments Panel and Handouts Panel
322
exports.MultipleAddSearch = MultipleAddSearch = rclass
323
propTypes :
324
add_selected : rtypes.func.isRequired # Submit user selected results add_selected(['paths', 'of', 'folders'])
325
do_search : rtypes.func.isRequired # Submit search query, invoked as do_search(value)
326
clear_search : rtypes.func.isRequired
327
is_searching : rtypes.bool.isRequired # whether or not it is asking the backend for the result of a search
328
search_results : rtypes.immutable.List # contents to put in the selection box after getting search result back
329
item_name : rtypes.string
330
331
getDefaultProps: ->
332
item_name : 'result'
333
334
getInitialState: ->
335
selected_items : [] # currently selected options
336
show_selector : false
337
338
shouldComponentUpdate: (newProps, newState) ->
339
return newProps.search_results != @props.search_results or
340
newProps.item_name != @props.item_name or
341
newProps.is_searching != @props.is_searching or
342
not underscore.isEqual(newState.selected_items, @state.selected_items)
343
344
componentWillReceiveProps: (newProps) ->
345
@setState
346
show_selector : newProps.search_results? and newProps.search_results.size > 0
347
348
clear_and_focus_search_input: ->
349
@props.clear_search()
350
@setState(selected_items:[])
351
352
search_button: ->
353
if @props.is_searching
354
# Currently doing a search, so show a spinner
355
<Button>
356
<Icon name="cc-icon-cocalc-ring" spin />
357
</Button>
358
else if @state.show_selector
359
# There is something in the selection box -- so only action is to clear the search box.
360
<Button onClick={@clear_and_focus_search_input}>
361
<Icon name="times-circle" />
362
</Button>
363
else
364
# Waiting for user to start a search
365
<Button onClick={(e)=>@refs.search_input.submit(e)}>
366
<Icon name="search" />
367
</Button>
368
369
add_button_clicked: (e) ->
370
e.preventDefault()
371
@props.add_selected(@state.selected_items)
372
@clear_and_focus_search_input()
373
374
change_selection: (e) ->
375
v = []
376
for option in e.target.selectedOptions
377
v.push(option.label)
378
@setState(selected_items : v)
379
380
render_results_list: ->
381
v = []
382
@props.search_results.map (item) =>
383
v.push(<option key={item} value={item} label={item}>{item}</option>)
384
return v
385
386
render_add_selector: ->
387
<FormGroup>
388
<FormControl componentClass='select' multiple ref="selector" size=5 rows=10 onChange={@change_selection}>
389
{@render_results_list()}
390
</FormControl>
391
<ButtonToolbar>
392
{@render_add_selector_button()}
393
<Button onClick={@clear_and_focus_search_input}>
394
Cancel
395
</Button>
396
</ButtonToolbar>
397
</FormGroup>
398
399
render_add_selector_button: ->
400
num_items_selected = @state.selected_items.length ? 0
401
btn_text = switch @props.search_results.size
402
when 0 then "No #{@props.item_name} found"
403
when 1 then "Add #{@props.item_name}"
404
else switch num_items_selected
405
when 0 then "Select #{@props.item_name} above"
406
when 1 then "Add selected #{@props.item_name}"
407
else "Add #{num_items_selected} #{@props.item_name}s"
408
<Button disabled={num_items_selected == 0} onClick={@add_button_clicked}><Icon name="plus" /> {btn_text}</Button>
409
410
render: ->
411
<div>
412
<SearchInput
413
autoFocus = {true}
414
ref = 'search_input'
415
default_value = ''
416
placeholder = "Add #{@props.item_name} by folder name (enter to see available folders)..."
417
on_submit = {@props.do_search}
418
on_clear = {@clear_and_focus_search_input}
419
buttonAfter = {@search_button()}
420
/>
421
{@render_add_selector() if @state.show_selector}
422
</div>
423
424
# Definitely not a good abstraction.
425
# Purely for code reuse (bad reason..)
426
# Complects FilterSearchBar and AddSearchBar...
427
exports.FoldersToolbar = rclass
428
propTypes :
429
search : rtypes.string
430
search_change : rtypes.func.isRequired # search_change(current_search_value)
431
num_omitted : rtypes.number
432
project_id : rtypes.string
433
items : rtypes.object.isRequired
434
add_folders : rtypes.func # add_folders (Iterable<T>)
435
item_name : rtypes.string
436
plural_item_name : rtypes.string
437
438
getDefaultProps: ->
439
item_name : "item"
440
plural_item_name : "items"
441
442
getInitialState: ->
443
add_is_searching : false
444
add_search_results : immutable.List([])
445
446
do_add_search: (search) ->
447
if @state.add_is_searching
448
return
449
@setState(add_is_searching:true)
450
webapp_client.find_directories
451
project_id : @props.project_id
452
query : "*#{search.trim()}*"
453
cb : (err, resp) =>
454
if err
455
@setState(add_is_searching:false, err:err, add_search_results:undefined)
456
else
457
filtered_results = @filter_results(resp.directories, search, @props.items)
458
if filtered_results.length == @state.add_search_results.size
459
merged = @state.add_search_results.merge(filtered_results)
460
else
461
merged = immutable.List(filtered_results)
462
@setState(add_is_searching:false, add_search_results:merged)
463
464
# Filter directories based on contents of all_items
465
filter_results: (directories, search, all_items) ->
466
if directories.length > 0
467
# Omit any -collect directory (unless explicitly searched for).
468
# Omit any currently assigned directory
469
paths_to_omit = []
470
471
active_items = all_items.filter (val) => not val.get('deleted')
472
active_items.map (val) =>
473
path = val.get('path')
474
if path # path might not be set in case something went wrong (this has been hit in production)
475
paths_to_omit.push(path)
476
477
should_omit = (path) =>
478
if path.indexOf('-collect') != -1 and search.indexOf('collect') == -1
479
# omit assignment collection folders unless explicitly searched (could cause confusion...)
480
return true
481
return paths_to_omit.includes(path)
482
483
directories = directories.filter (x) => not should_omit(x)
484
directories.sort()
485
return directories
486
487
submit_selected: (path_list) ->
488
if path_list?
489
# If nothing is selected and the user clicks the button to "Add handout (etc)" then
490
# path_list is undefined, hence don't do this.
491
# (NOTE: I'm also going to make it so that button is disabled, which fits our
492
# UI guidelines, so there's two reasons that path_list is defined here.)
493
@props.add_folders(path_list)
494
@clear_add_search()
495
496
clear_add_search: ->
497
@setState(add_search_results:immutable.List([]))
498
499
render: ->
500
<Row style={marginBottom:'-15px'}>
501
<Col md=3>
502
<SearchInput
503
placeholder = {"Find #{@props.plural_item_name}..."}
504
default_value = {@props.search}
505
on_change = {@props.search_change}
506
/>
507
</Col>
508
<Col md=4>
509
{<h5>(Omitting {@props.num_omitted} {if @props.num_ommitted > 1 then @props.plural_item_name else @props.item_name})</h5> if @props.num_omitted}
510
</Col>
511
<Col md=5>
512
<MultipleAddSearch
513
add_selected = {@submit_selected}
514
do_search = {@do_add_search}
515
clear_search = {@clear_add_search}
516
is_searching = {@state.add_is_searching}
517
item_name = {@props.item_name}
518
err = {undefined}
519
search_results = {@state.add_search_results}
520
/>
521
</Col>
522
</Row>
523