metadata.js 21 KB
Newer Older
1 2
define(
    [
3 4 5 6 7
        'js/views/baseview', 'underscore', 'js/models/metadata', 'js/views/abstract_editor',
        'js/models/uploads', 'js/views/uploads',
        'js/models/license', 'js/views/license',
        'js/views/video/transcripts/metadata_videolist',
        'js/views/video/translations_editor'
8
    ],
9 10
function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog,
         LicenseModel, LicenseView, VideoList, VideoTranslations) {
11 12
    var Metadata = {};

13
    Metadata.Editor = BaseView.extend({
14 15

        // Model is CMS.Models.MetadataCollection,
16
        initialize: function() {
polesye committed
17 18
            var self = this,
                counter = 0,
19 20
                locator = self.$el.closest('[data-locator]').data('locator'),
                courseKey = self.$el.closest('[data-course-key]').data('course-key');
21

polesye committed
22
            this.template = this.loadTemplate('metadata-editor');
23
            this.$el.html(this.template({numEntries: this.collection.length, locator: locator}));
24 25

            this.collection.each(
26
                function(model) {
27
                    var data = {
28
                            el: self.$el.find('.metadata_entry')[counter++],
29
                            courseKey: courseKey,
polesye committed
30
                            locator: locator,
31 32 33 34 35 36 37 38 39 40 41
                            model: model
                        },
                        conversions = {
                            'Select': 'Option',
                            'Float': 'Number',
                            'Integer': 'Number'
                        },
                        type = model.getType();

                    if (conversions[type]) {
                        type = conversions[type];
42
                    }
43 44 45 46

                    if (_.isFunction(Metadata[type])) {
                        new Metadata[type](data);
                    } else {
47 48 49 50 51 52 53
                        // Everything else is treated as GENERIC_TYPE, which uses String editor.
                        new Metadata.String(data);
                    }
                });
        },

        /**
54
         * Returns just the modified metadata values, in the format used to persist to the server.
55
         */
56
        getModifiedMetadataValues: function() {
57 58
            var modified_values = {};
            this.collection.each(
59
                function(model) {
60 61 62 63 64 65 66 67 68 69 70 71 72
                    if (model.isModified()) {
                        modified_values[model.getFieldName()] = model.getValue();
                    }
                }
            );
            return modified_values;
        },

        /**
         * Returns a display name for the component related to this metadata. This method looks to see
         * if there is a metadata entry called 'display_name', and if so, it returns its value. If there
         * is no such entry, or if display_name does not have a value set, it returns an empty string.
         */
73
        getDisplayName: function() {
74 75
            var displayName = '';
            this.collection.each(
76
                function(model) {
77 78 79 80 81 82 83 84 85 86 87
                    if (model.get('field_name') === 'display_name') {
                        var displayNameValue = model.get('value');
                        // It is possible that there is no display name value set. In that case, return empty string.
                        displayName = displayNameValue ? displayNameValue : '';
                    }
                }
            );
            return displayName;
        }
    });

88
    Metadata.VideoList = VideoList;
89
    Metadata.VideoTranslations = VideoTranslations;
90

91
    Metadata.String = AbstractEditor.extend({
92

93 94 95 96
        events: {
            'change input': 'updateModel',
            'keypress .setting-input': 'showClearButton',
            'click .setting-clear': 'clear'
97 98
        },

99
        templateName: 'metadata-string-entry',
100

101
        render: function() {
102 103 104 105 106 107 108
            AbstractEditor.prototype.render.apply(this);

            // If the model has property `non editable` equals `true`,
            // the field is disabled, but user is able to clear it.
            if (this.model.get('non_editable')) {
                this.$el.find('#' + this.uniqueId)
                    .prop('readonly', true)
109 110
                    .addClass('is-disabled')
                    .attr('aria-disabled', true);
111 112 113
            }
        },

114
        getValueFromEditor: function() {
115 116 117
            return this.$el.find('#' + this.uniqueId).val();
        },

118
        setValueInEditor: function(value) {
119 120 121 122
            this.$el.find('input').val(value);
        }
    });

123
    Metadata.Number = AbstractEditor.extend({
124

125 126 127 128 129
        events: {
            'change input': 'updateModel',
            'keypress .setting-input': 'keyPressed',
            'change .setting-input': 'changed',
            'click .setting-clear': 'clear'
130 131
        },

132
        render: function() {
133
            AbstractEditor.prototype.render.apply(this);
134
            if (!this.initialized) {
135
                var numToString = function(val) {
136 137
                    return val.toFixed(4);
                };
138 139 140
                var min = 'min';
                var max = 'max';
                var step = 'step';
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155
                var options = this.model.getOptions();
                if (options.hasOwnProperty(min)) {
                    this.min = Number(options[min]);
                    this.$el.find('input').attr(min, numToString(this.min));
                }
                if (options.hasOwnProperty(max)) {
                    this.max = Number(options[max]);
                    this.$el.find('input').attr(max, numToString(this.max));
                }
                var stepValue = undefined;
                if (options.hasOwnProperty(step)) {
                    // Parse step and convert to String. Polyfill doesn't like float values like ".1" (expects "0.1").
                    stepValue = numToString(Number(options[step]));
                }
                else if (this.isIntegerField()) {
156
                    stepValue = '1';
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173
                }
                if (stepValue !== undefined) {
                    this.$el.find('input').attr(step, stepValue);
                }

                // Manually runs polyfill for input number types to correct for Firefox non-support.
                // inputNumber will be undefined when unit test is running.
                if ($.fn.inputNumber) {
                    this.$el.find('.setting-input-number').inputNumber();
                }

                this.initialized = true;
            }

            return this;
        },

174
        templateName: 'metadata-number-entry',
175

176
        getValueFromEditor: function() {
177 178 179
            return this.$el.find('#' + this.uniqueId).val();
        },

180
        setValueInEditor: function(value) {
181 182 183 184 185 186
            this.$el.find('input').val(value);
        },

        /**
         * Returns true if this view is restricted to integers, as opposed to floating points values.
         */
187
        isIntegerField: function() {
188 189 190
            return this.model.getType() === 'Integer';
        },

191
        keyPressed: function(e) {
192 193 194 195 196 197 198 199 200 201 202 203 204 205 206
            this.showClearButton();
            // This first filtering if statement is take from polyfill to prevent
            // non-numeric input (for browsers that don't use polyfill because they DO have a number input type).
            var _ref, _ref1;
            if (((_ref = e.keyCode) !== 8 && _ref !== 9 && _ref !== 35 && _ref !== 36 && _ref !== 37 && _ref !== 39) &&
                ((_ref1 = e.which) !== 45 && _ref1 !== 46 && _ref1 !== 48 && _ref1 !== 49 && _ref1 !== 50 && _ref1 !== 51
                    && _ref1 !== 52 && _ref1 !== 53 && _ref1 !== 54 && _ref1 !== 55 && _ref1 !== 56 && _ref1 !== 57)) {
                e.preventDefault();
            }
            // For integers, prevent decimal points.
            if (this.isIntegerField() && e.keyCode === 46) {
                e.preventDefault();
            }
        },

207
        changed: function() {
208
            // Limit value to the range specified by min and max (necessary for browsers that aren't using polyfill).
209
            // Prevent integer/float fields value to be empty (set them to their defaults)
210
            var value = this.getValueFromEditor();
211 212 213 214 215 216 217 218 219 220
            if (value) {
                if ((this.max !== undefined) && value > this.max) {
                    value = this.max;
                } else if ((this.min != undefined) && value < this.min) {
                    value = this.min;
                }
                this.setValueInEditor(value);
                this.updateModel();
            } else {
                this.clear();
221 222 223 224 225
            }
        }

    });

226
    Metadata.Option = AbstractEditor.extend({
227

228 229 230
        events: {
            'change select': 'updateModel',
            'click .setting-clear': 'clear'
231 232
        },

233
        templateName: 'metadata-option-entry',
234

235 236
        getValueFromEditor: function() {
            var selectedText = this.$el.find('#' + this.uniqueId).find(':selected').text();
237
            var selectedValue;
238
            _.each(this.model.getOptions(), function(modelValue) {
239 240 241 242 243 244 245 246 247 248
                if (modelValue === selectedText) {
                    selectedValue = modelValue;
                }
                else if (modelValue['display_name'] === selectedText) {
                    selectedValue = modelValue['value'];
                }
            });
            return selectedValue;
        },

249
        setValueInEditor: function(value) {
250 251
            // Value here is the json value as used by the field. The choice may instead be showing display names.
            // Find the display name matching the value passed in.
252
            _.each(this.model.getOptions(), function(modelValue) {
253 254 255 256
                if (modelValue['value'] === value) {
                    value = modelValue['display_name'];
                }
            });
257
            this.$el.find('#' + this.uniqueId + ' option').filter(function() {
258 259 260 261 262
                return $(this).text() === value;
            }).prop('selected', true);
        }
    });

263
    Metadata.List = AbstractEditor.extend({
264

265 266 267 268 269 270 271
        events: {
            'click .setting-clear': 'clear',
            'keypress .setting-input': 'showClearButton',
            'change input': 'updateModel',
            'input input': 'enableAdd',
            'click .create-setting': 'addEntry',
            'click .remove-setting': 'removeEntry'
272 273
        },

274
        templateName: 'metadata-list-entry',
275

276
        getValueFromEditor: function() {
277 278
            return _.map(
                this.$el.find('li input'),
279
                function(ele) { return ele.value.trim(); }
280 281 282
            ).filter(_.identity);
        },

283
        setValueInEditor: function(value) {
284
            var list = this.$el.find('ol');
285

286 287 288 289
            list.empty();
            _.each(value, function(ele, index) {
                var template = _.template(
                    '<li class="list-settings-item">' +
290
                        '<input type="text" class="input" value="<%- ele %>">' +
291
                        '<a href="#" class="remove-action remove-setting" data-index="<%- index %>"><span class="icon fa fa-times-circle" aria-hidden="true"></span><span class="sr">' + gettext('Remove') + '</span></a>' +   // eslint-disable-line max-len
292 293 294 295 296 297 298 299 300 301 302
                    '</li>'
                );
                list.append($(template({'ele': ele, 'index': index})));
            });
        },

        addEntry: function(event) {
            event.preventDefault();
            // We don't call updateModel here since it's bound to the
            // change event
            var list = this.model.get('value') || [];
303
            this.setValueInEditor(list.concat(['']));
304
            this.$el.find('.create-setting').addClass('is-disabled').attr('aria-disabled', true);
305 306 307 308 309 310 311
        },

        removeEntry: function(event) {
            event.preventDefault();
            var entry = $(event.currentTarget).siblings().val();
            this.setValueInEditor(_.without(this.model.get('value'), entry));
            this.updateModel();
312
            this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false);
313 314 315
        },

        enableAdd: function() {
316
            this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false);
317 318 319 320 321
        },

        clear: function() {
            AbstractEditor.prototype.clear.apply(this, arguments);
            if (_.isNull(this.model.getValue())) {
322
                this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false);
323
            }
324 325 326
        }
    });

327
    Metadata.RelativeTime = AbstractEditor.extend({
polesye committed
328

329
        defaultValue: '00:00:00',
330 331
        // By default max value of RelativeTime field on Backend is 23:59:59,
        // that is 86399 seconds.
332
        maxTimeInSeconds: 86399,
333

334
        events: {
335 336 337 338 339
            'focus input': 'addSelection',
            'mouseup input': 'mouseUpHandler',
            'change input': 'updateModel',
            'keypress .setting-input': 'showClearButton',
            'click .setting-clear': 'clear'
polesye committed
340 341
        },

342
        templateName: 'metadata-string-entry',
polesye committed
343

344
        getValueFromEditor: function() {
345
            var $input = this.$el.find('#' + this.uniqueId);
polesye committed
346

347
            return $input.val();
polesye committed
348 349
        },

350
        updateModel: function() {
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366
            var value = this.getValueFromEditor(),
                time = this.parseRelativeTime(value);

            this.model.setValue(time);

            // Sometimes, `parseRelativeTime` method returns the same value for
            // the different inputs. In this case, model will not be
            // updated (it already has the same value) and we should
            // call `render` method manually.
            // Examples:
            //   value => 23:59:59; parseRelativeTime => 23:59:59
            //   value => 44:59:59; parseRelativeTime => 23:59:59
            if (value !== time && !this.model.hasChanged('value')) {
                this.render();
            }
        },
polesye committed
367

368
        parseRelativeTime: function(value) {
369
            // This function ensure you have two-digits
370 371
            var pad = function(number) {
                    return (number < 10) ? '0' + number : number;
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392
                },
                // Removes all white-spaces and splits by `:`.
                list = value.replace(/\s+/g, '').split(':'),
                seconds, date;

            list = _.map(list, function(num) {
                return Math.max(0, parseInt(num, 10) || 0);
            }).reverse();

            seconds = _.reduce(list, function(memo, num, index) {
                return memo + num * Math.pow(60, index);
            }, 0);

            // multiply by 1000 because Date() requires milliseconds
            date = new Date(Math.min(seconds, this.maxTimeInSeconds) * 1000);

            return [
                pad(date.getUTCHours()),
                pad(date.getUTCMinutes()),
                pad(date.getUTCSeconds())
            ].join(':');
polesye committed
393 394
        },

395
        setValueInEditor: function(value) {
polesye committed
396
            if (!value) {
397
                value = this.defaultValue;
polesye committed
398 399 400
            }

            this.$el.find('input').val(value);
401 402
        },

403
        addSelection: function(event) {
404 405 406
            $(event.currentTarget).select();
        },

407
        mouseUpHandler: function(event) {
408 409 410
            // Prevents default behavior to make works selection in WebKit
            // browsers
            event.preventDefault();
polesye committed
411 412 413
        }
    });

414 415
    Metadata.Dict = AbstractEditor.extend({

416
        events: {
417 418 419 420 421 422
            'click .setting-clear': 'clear',
            'keypress .setting-input': 'showClearButton',
            'change input': 'updateModel',
            'input input': 'enableAdd',
            'click .create-setting': 'addEntry',
            'click .remove-setting': 'removeEntry'
423 424
        },

425
        templateName: 'metadata-dict-entry',
426

427
        getValueFromEditor: function() {
428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448
            var dict = {};

            _.each(this.$el.find('li'), function(li, index) {
                var key = $(li).find('.input-key').val().trim(),
                    value = $(li).find('.input-value').val().trim();

                // Keys should be unique, so if our keys are duplicated and
                // second key is empty or key and value are empty just do
                // nothing. Otherwise, it'll be overwritten by the new value.
                if (value === '') {
                    if (key === '' || key in dict) {
                        return false;
                    }
                }

                dict[key] = value;
            });

            return dict;
        },

449
        setValueInEditor: function(value) {
450 451 452 453 454 455 456 457
            var list = this.$el.find('ol'),
                frag = document.createDocumentFragment();

            _.each(value, function(value, key) {
                var template = _.template(
                    '<li class="list-settings-item">' +
                        '<input type="text" class="input input-key" value="<%= key %>">' +
                        '<input type="text" class="input input-value" value="<%= value %>">' +
458
                        '<a href="#" class="remove-action remove-setting" data-value="<%= value %>"><span class="icon fa fa-times-circle" aria-hidden="true"></span><span class="sr">Remove</span></a>' +  // eslint-disable-line max-len
459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474
                    '</li>'
                );

                frag.appendChild($(template({'key': key, 'value': value}))[0]);
            });

            list.html([frag]);
        },

        addEntry: function(event) {
            event.preventDefault();
            // We don't call updateModel here since it's bound to the
            // change event
            var dict = $.extend(true, {}, this.model.get('value')) || {};
            dict[''] = '';
            this.setValueInEditor(dict);
475
            this.$el.find('.create-setting').addClass('is-disabled').attr('aria-disabled', true);
476 477 478 479 480 481 482
        },

        removeEntry: function(event) {
            event.preventDefault();
            var entry = $(event.currentTarget).siblings('.input-key').val();
            this.setValueInEditor(_.omit(this.model.get('value'), entry));
            this.updateModel();
483
            this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false);
484 485 486
        },

        enableAdd: function() {
487
            this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false);
488 489 490 491 492
        },

        clear: function() {
            AbstractEditor.prototype.clear.apply(this, arguments);
            if (_.isNull(this.model.getValue())) {
493
                this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false);
494 495 496 497
            }
        }
    });

polesye committed
498 499 500 501 502 503 504 505

    /**
     * Provides convenient way to upload/download files in component edit.
     * The editor uploads files directly to course assets and stores link
     * to uploaded file.
     */
    Metadata.FileUploader = AbstractEditor.extend({

506 507 508
        events: {
            'click .upload-setting': 'upload',
            'click .setting-clear': 'clear'
polesye committed
509 510
        },

511 512
        templateName: 'metadata-file-uploader-entry',
        templateButtonsName: 'metadata-file-uploader-item',
polesye committed
513

514
        initialize: function() {
polesye committed
515 516 517 518
            this.buttonTemplate = this.loadTemplate(this.templateButtonsName);
            AbstractEditor.prototype.initialize.apply(this);
        },

519
        getValueFromEditor: function() {
polesye committed
520 521 522
            return this.$('#' + this.uniqueId).val();
        },

523
        setValueInEditor: function(value) {
polesye committed
524 525 526 527 528 529 530 531 532
            var html = this.buttonTemplate({
                model: this.model,
                uniqueId: this.uniqueId
            });

            this.$('#' + this.uniqueId).val(value);
            this.$('.wrapper-uploader-actions').html(html);
        },

533
        upload: function(event) {
polesye committed
534 535
            var self = this,
                target = $(event.currentTarget),
536
                url = '/assets/' + this.options.courseKey + '/',
polesye committed
537
                model = new FileUpload({
538
                    title: gettext('Upload File')
polesye committed
539 540 541 542 543
                }),
                view = new UploadDialog({
                    model: model,
                    url: url,
                    parentElement: target.closest('.xblock-editor'),
544
                    onSuccess: function(response) {
polesye committed
545 546 547 548 549 550 551 552 553 554
                        if (response['asset'] && response['asset']['url']) {
                            self.model.setValue(response['asset']['url']);
                        }
                    }
                }).show();

            event.preventDefault();
        }
    });

555 556 557
    Metadata.License = AbstractEditor.extend({

        initialize: function(options) {
558
            this.licenseModel = new LicenseModel({'asString': this.model.getValue()});
559 560 561 562 563 564 565 566
            this.licenseView = new LicenseView({model: this.licenseModel});

            // Rerender when the license model changes
            this.listenTo(this.licenseModel, 'change', this.setLicense);
            this.render();
        },

        render: function() {
567
            this.licenseView.render().$el.css('display', 'inline');
568
            this.licenseView.undelegateEvents();
569
            this.$el.empty().append(this.licenseView.el);
570 571 572 573 574 575 576
            // restore event bindings
            this.licenseView.delegateEvents();
            return this;
        },

        setLicense: function() {
            this.model.setValue(this.licenseModel.toString());
577
            this.render();
578 579 580 581
        }

    });

582 583
    return Metadata;
});