1 /*global window: true, GCN: true, jQuery: true*/ 2 (function (GCN) { 3 4 'use strict'; 5 6 /** 7 * Searches for the an Aloha editable object of the given id. 8 * 9 * @TODO: Once Aloha.getEditableById() is patched to not cause an 10 * JavaScript exception if the element for the given ID is not found 11 * then we can deprecate this function and use Aloha's instead. 12 * 13 * @static 14 * @param {string} id Id of Aloha.Editable object to find. 15 * @return {Aloha.Editable=} The editable object, if wound; otherwise null. 16 */ 17 function getAlohaEditableById(id) { 18 var Aloha = (typeof window !== 'undefined') && window.Aloha; 19 if (!Aloha) { 20 return null; 21 } 22 23 // If the element is a textarea then route to the editable div. 24 var element = jQuery('#' + id); 25 if (element.length && 26 element[0].nodeName.toLowerCase() === 'textarea') { 27 id += '-aloha'; 28 } 29 30 var editables = Aloha.editables; 31 var j = editables.length; 32 while (j) { 33 if (editables[--j].getId() === id) { 34 return editables[j]; 35 } 36 } 37 38 return null; 39 } 40 41 /** 42 * Helper function to normalize the arguments that can be passed to the 43 * `edit()' and `render()' methods. 44 * 45 * @private 46 * @static 47 * @param {arguments} args A list of arguments. 48 * @return {object} Object containing an the properties `element', 49 * `success', `error', `data' and `post'. 50 */ 51 function getRenderOptions(args) { 52 var argv = Array.prototype.slice.call(args); 53 var argc = args.length; 54 var arg; 55 var i; 56 57 var element; 58 var success; 59 var error; 60 var prerenderedData = false; 61 var post = false; 62 63 for (i = 0; i < argc; ++i) { 64 arg = argv[i]; 65 66 switch (jQuery.type(arg)) { 67 case 'string': 68 element = jQuery(arg); 69 break; 70 case 'object': 71 if (element) { 72 prerenderedData = arg; 73 } else { 74 element = arg; 75 } 76 break; 77 case 'function': 78 if (success) { 79 error = arg; 80 } else { 81 success = arg; 82 } 83 break; 84 case 'boolean': 85 post = arg; 86 break; 87 // Descarding all other types of arguments... 88 } 89 } 90 91 return { 92 element : element, 93 success : success, 94 error : error, 95 data : prerenderedData, 96 post : post 97 }; 98 } 99 100 /** 101 * Exposes an API to operate on a Content.Node tag. 102 * 103 * @class 104 * @name TagAPI 105 */ 106 var TagAPI = GCN.defineChainback({ 107 108 __chainbacktype__: 'TagAPI', 109 110 /** 111 * Type of the object 112 * 113 * @type {string} 114 */ 115 _type: 'tag', 116 117 /** 118 * A reference to the object in which this tag is contained. This value 119 * is set during initialization. 120 * 121 * @type {GCN.ContentObject} 122 */ 123 _parent: null, 124 125 /** 126 * Name of this tag. 127 * 128 * @type {string} 129 */ 130 _name: null, 131 132 /** 133 * Gets this tag's information from the object that contains it. 134 * 135 * @param {function(TagAPI)} success Callback to be invoked when this 136 * operation completes normally. 137 * @param {function(GCNError):boolean} error Custom error handler. 138 */ 139 '!_read': function (success, error) { 140 var parent = this.parent(); 141 // Because tags always retrieve their data from a parent object, 142 // this tag is only completely fetched if it's parent is also fetch. 143 // The parent could have been cleared of all it's data using 144 // _clearCache() while this tag was left in a _fetched state, so we 145 // need to check. 146 if (this._fetched && parent._fetched) { 147 if (success) { 148 this._invoke(success, [this]); 149 } 150 return; 151 } 152 153 // Because when loading folders via folder(1).folders() will 154 // fetch them without any tag data. We therefore have to refetch 155 // them wit their tag data. 156 if (parent._fetched && !parent._data.tags) { 157 parent._data.tags = {}; 158 parent.fetch(function (response) { 159 if (GCN.getResponseCode(response) !== 'OK') { 160 GCN.handleResponseError(response); 161 return; 162 } 163 var newTags = {}; 164 jQuery.each( 165 response[parent._type].tags, 166 function (name, data) { 167 if (!GCN.TagContainerAPI.hasTagData(parent, name)) { 168 newTags[name] = data; 169 } 170 } 171 ); 172 GCN.TagContainerAPI.extendTags(parent, newTags); 173 parent._read(success, error); 174 }); 175 return; 176 } 177 178 var that = this; 179 180 // Take the data for this tag from it's container. 181 parent._read(function () { 182 that._data = parent._getTagData(that._name); 183 184 if (!that._data) { 185 var err = GCN.createError('TAG_NOT_FOUND', 186 'Could not find tag "' + that._name + '" in ' + 187 parent._type + " " + parent._data.id, that); 188 GCN.handleError(err, error); 189 return; 190 } 191 192 that._fetched = true; 193 194 if (success) { 195 that._invoke(success, [that]); 196 } 197 }, error); 198 }, 199 200 /** 201 * Retrieve the object in which this tag is contained. It does so by 202 * getting this chainback's "chainlink ancestor" object. 203 * 204 * @function 205 * @name parent 206 * @memberOf TagAPI 207 * @return {GCN.AbstractTagContainer} 208 */ 209 '!parent': function () { 210 return this._ancestor(); 211 }, 212 213 /** 214 * Initialize a tag object. Unlike other chainback objects, tags will 215 * always have a parent. If its parent have been loaded, we will 216 * immediately copy the this tag's data from the parent's `_data' object 217 * to the tag's `_data' object. 218 * 219 * @param {string|object} 220 * settings 221 * @param {function(TagAPI)} 222 * success Callback to be invoked when this operation 223 * completes normally. 224 * @param {function(GCNError):boolean} 225 * error Custom error handler. 226 */ 227 _init: function (settings, success, error) { 228 if (jQuery.type(settings) === 'object') { 229 this._name = settings.name; 230 this._data = settings; 231 this._data.id = settings.id; 232 this._fetched = true; 233 } else { 234 // We don't want to reinitalize the data object when it 235 // has not been fetched yet. 236 if (!this._fetched) { 237 this._data = {}; 238 this._data.id = this._name = settings; 239 } 240 } 241 242 if (success) { 243 var that = this; 244 245 this._read(function (container) { 246 that._read(success, error); 247 }, error); 248 249 // Even if not success callback is given, read this tag's data from 250 // is container, if that container has the data available. 251 // If we are initializing a placeholder tag object (in the process 252 // of creating brand new tag, for example), then its parent 253 // container will not have any data for this tag yet. We know that 254 // we are working with a placeholder tag if no `_data.id' or `_name' 255 // property is set. 256 } else if (!this._fetched && this._name && 257 this.parent()._fetched) { 258 this._data = this.parent()._getTagData(this._name); 259 this._fetched = !!this._data; 260 261 // We are propably initializing a placholder object, we will assign 262 // it its own `_data' and `_fetched' properties so that it is not 263 // accessing the prototype values. 264 } else if (!this._fetched) { 265 this._data = {}; 266 this._data.id = this._name = settings; 267 this._fetched = false; 268 } 269 }, 270 271 /** 272 * Gets or sets a property of this tags. Note that tags do not have a 273 * `_shadow' object, and we update the `_data' object directly. 274 * 275 * @function 276 * @name prop 277 * @memberOf TagAPI 278 * @param {string} 279 * name Name of tag part. 280 * @param {*=} 281 * set Optional value. If provided, the tag part will be 282 * replaced with this value. 283 * @return {*} The value of the accessed tag part. 284 * @throws UNFETCHED_OBJECT_ACCESS 285 */ 286 '!prop': function (name, value) { 287 var parent = this.parent(); 288 289 if (!this._fetched) { 290 GCN.error('UNFETCHED_OBJECT_ACCESS', 291 'Calling method `prop()\' on an unfetched object: ' + 292 parent._type + " " + parent._data.id, this); 293 294 return; 295 } 296 297 if (jQuery.type(value) !== 'undefined') { 298 this._data[name] = value; 299 parent._update('tags.' + GCN.escapePropertyName(this.prop('name')), 300 this._data); 301 } 302 303 return this._data[name]; 304 }, 305 306 /** 307 * <p> 308 * Gets or sets a part of a tag. 309 * 310 * <p> 311 * There exists different types of tag parts, and the possible value of 312 * each kind of tag part may differ. 313 * 314 * <p> 315 * Below is a list of possible kinds of tag parts, and references to 316 * what the possible range their values can take: 317 * 318 * <pre> 319 * STRING : {@link TagParts.STRING} 320 * RICHTEXT : {@link TagParts.RICHTEXT} 321 * BOOLEAN : {@link TagParts.BOOLEAN} 322 * IMAGE : {@link TagParts.IMAGE} 323 * FILE : {@link TagParts.FILE} 324 * FOLDER : {@link TagParts.FOLDER} 325 * PAGE : {@link TagParts.PAGE} 326 * OVERVIEW : {@link TagParts.OVERVIEW} 327 * PAGETAG : {@link TagParts.PAGETAG} 328 * TEMPLATETAG : {@link TagParts.TEMPLATETAG} 329 * SELECT : {@link TagParts.SELECT} 330 * MULTISELECT : {@link TagParts.MULTISELECT} 331 * FORM : {@link TagParts.FORM} 332 * </pre> 333 * 334 * @function 335 * @name part 336 * @memberOf TagAPI 337 * 338 * @param {string} name Name of tag opart. 339 * @param {*=} value (optional) 340 * If provided, the tag part will be update with this 341 * value. How this happens differs between different type 342 * of tag parts. 343 * @return {*} The value of the accessed tag part. Null if the part 344 * does not exist. 345 * @throws UNFETCHED_OBJECT_ACCESS 346 */ 347 '!part': function (name, value) { 348 if (!this._fetched) { 349 var parent = this.parent(); 350 351 GCN.error( 352 'UNFETCHED_OBJECT_ACCESS', 353 'Calling method `prop()\' on an unfetched object: ' 354 + parent._type + " " + parent._data.id, 355 this 356 ); 357 358 return null; 359 } 360 361 var part = this._data.properties[name]; 362 363 if (!part) { 364 return null; 365 } 366 367 if (jQuery.type(value) === 'undefined') { 368 return GCN.TagParts.get(part); 369 } 370 371 var partValue = GCN.TagParts.set(part, value); 372 373 // Each time we perform a write operation on a tag, we will update 374 // the tag in the tag container's `_shadow' object as well. 375 this.parent()._update( 376 'tags.' + GCN.escapePropertyName(this._name), 377 this._data 378 ); 379 380 return partValue; 381 }, 382 383 /** 384 * Returns a list of all of this tag's parts. 385 * 386 * @function 387 * @memberOf TagAPI 388 * @name parts 389 * @param {string} name 390 * @return {Array.<string>} 391 */ 392 '!parts': function (name) { 393 var parts = []; 394 jQuery.each(this._data.properties, function (key) { 395 parts.push(key); 396 }); 397 return parts; 398 }, 399 400 /** 401 * Remove this tag from its containing object (it's parent). 402 * 403 * @function 404 * @memberOf TagAPI 405 * @name remove 406 * @param {function} callback A function that receive this tag's parent 407 * object as its only arguments. 408 */ 409 remove: function (success, error) { 410 var parent = this.parent(); 411 412 if (!parent.hasOwnProperty('_deletedTags')) { 413 parent._deletedTags = []; 414 } 415 416 GCN.pub('tag.before-deleted', {tag: this}); 417 418 parent._deletedTags.push(this._name); 419 420 if (parent._data.tags && 421 parent._data.tags[this._name]) { 422 delete parent._data.tags[this._name]; 423 } 424 425 if (parent._shadow.tags && 426 parent._shadow.tags[this._name]) { 427 delete parent._shadow.tags[this._name]; 428 } 429 430 parent._removeAssociatedTagData(this._name); 431 432 this._clearCache(); 433 434 if (success) { 435 parent._persist(null, success, error); 436 } 437 }, 438 439 /** 440 * Given a DOM element, will generate a template which represents this 441 * tag as it would be if rendered in the element. 442 * 443 * @param {jQuery.<HTMLElement>} $element DOM element with which to 444 * generate the template. 445 * @return {string} Template string. 446 */ 447 '!_makeTemplate': function ($element) { 448 if (0 === $element.length) { 449 return '<node ' + this._name + '>'; 450 } 451 var placeholder = 452 '-{(' + this.parent().id() + ':' + this._name + ')}-'; 453 var template = jQuery.trim( 454 $element.clone().html(placeholder)[0].outerHTML 455 ); 456 return template.replace(placeholder, '<node ' + this._name + '>'); 457 }, 458 459 /** 460 * Will render this tag in the given render `mode'. If an element is 461 * provided, the content will be placed in that element. If the `mode' 462 * is "edit", any rendered editables will be initialized for Aloha 463 * Editor. Any editable that are rendered into an element will also be 464 * added to the tag's parent object's `_editables' array so that they 465 * can have their changed contents copied back into their corresponding 466 * tags during saving. 467 * 468 * @param {string} mode The rendering mode. Valid values are "view", 469 * and "edit". 470 * @param {jQuery.<HTMLElement>} element DOM element into which the 471 * the rendered content should be 472 * placed. 473 * @param {function(string, TagAPI, object)} Optional success handler. 474 * @param {function(GCNError):boolean} Optional custom error handler. 475 * @param {boolean} post flag to POST the data for rendering 476 */ 477 '!_render': function (mode, $element, success, error, post) { 478 var tag = this._fork(); 479 tag._read(function () { 480 var template = ($element && $element.length) 481 ? tag._makeTemplate($element) 482 : '<node ' + tag._name + '>'; 483 484 var obj = tag.parent(); 485 var renderHandler = function (data) { 486 // Because the parent content object needs to track any 487 // blocks or editables that have been rendered in this tag. 488 obj._processRenderedTags(data); 489 490 GCN._handleContentRendered(data.content, tag, 491 function (html) { 492 if ($element && $element.length) { 493 GCN.renderOnto($element, html); 494 // Because 'content-inserted' is deprecated by 495 // 'tag.inserted'. 496 GCN.pub('content-inserted', [$element, html]); 497 GCN.pub('tag.inserted', [$element, html]); 498 } 499 500 var frontendEditing = function (callback) { 501 if ('edit' === mode) { 502 // Because 'rendered-for-editing' is deprecated by 503 // 'tag.rendered-for-editing'. 504 GCN.pub('rendered-for-editing', { 505 tag: tag, 506 data: data, 507 callback: callback 508 }); 509 GCN.pub('tag.rendered-for-editing', { 510 tag: tag, 511 data: data, 512 callback: callback 513 }); 514 } else if (callback) { 515 callback(); 516 } 517 }; 518 519 // Because the caller of edit() my wish to do things 520 // in addition to, or instead of, our frontend 521 // initialization. 522 if (success) { 523 tag._invoke( 524 success, 525 [html, tag, data, frontendEditing] 526 ); 527 } else { 528 frontendEditing(); 529 } 530 531 tag._merge(); 532 }); 533 }; 534 var errorHandler = function () { 535 tag._merge(); 536 }; 537 538 if ('edit' === mode && obj._previewEditableTag) { 539 obj._previewEditableTag(tag._name, renderHandler, errorHandler); 540 } else { 541 obj._renderTemplate(template, mode, renderHandler, errorHandler, post); 542 } 543 }, error); 544 }, 545 546 /** 547 * <p> 548 * Render the tag based on its settings on the server. Can be called 549 * with the following arguments:<(p> 550 * 551 * <pre> 552 * // Render tag contents into div whose id is "content-div" 553 * render('#content-div') or render(jQuery('#content-div')) 554 * </pre> 555 * 556 * <pre> 557 * // Pass the html rendering of the tag in the given callback 558 * render(function(html, tag) { 559 * // implementation! 560 * }) 561 * </pre> 562 * 563 * Whenever a 2nd argument is provided, it will be taken as as custom 564 * error handler. Invoking render() without any arguments will yield no 565 * results. 566 * 567 * @function 568 * @name render 569 * @memberOf TagAPI 570 * @param {string|jQuery.HTMLElement} 571 * selector jQuery selector or jQuery target element to be 572 * used as render destination 573 * @param {function(string, 574 * GCN.TagAPI)} success success function that will receive 575 * the rendered html as well as the TagAPI object 576 * @param {boolean} post 577 * True, when the tag shall be rendered by POSTing the data to 578 * the REST API. Otherwise the tag is rendered with a GET call 579 */ 580 render: function () { 581 var tag = this; 582 var args = arguments; 583 jQuery(function () { 584 args = getRenderOptions(args); 585 if (args.element || args.success) { 586 tag._render( 587 'view', 588 args.element, 589 args.success, 590 args.error, 591 args.post 592 ); 593 } 594 }); 595 }, 596 597 /** 598 * <p> 599 * Renders this tag for editing. 600 * </p> 601 * 602 * <p> 603 * Differs from the render() method in that it calls this tag to be 604 * rendered in "edit" mode via the REST API so that it is rendered with 605 * any additional content that is appropriate for when this tag is used 606 * in edit mode. 607 * </p> 608 * 609 * <p> 610 * The GCN JS API library will also start keeping track of various 611 * aspects of this tag and its rendered content. 612 * </p> 613 * 614 * <p> 615 * When a jQuery selector is passed to this method, the contents of the 616 * rendered tag will overwrite the element identified by that selector. 617 * All rendered blocks and editables will be automatically placed into 618 * the DOM and initialize for editing. 619 * </p> 620 * 621 * <p> 622 * The behavior is different when this method is called with a function 623 * as its first argument. In this case the rendered contents of the tag 624 * will not be autmatically placed into the DOM, but will be passed onto 625 * the callback function as argmuments. It is then up to the caller to 626 * place the content into the DOM and initialize all rendered blocks and 627 * editables appropriately. 628 * </p> 629 * 630 * @function 631 * @name edit 632 * @memberOf TagAPI 633 * @param {(string|jQuery.HTMLElement)=} element 634 * The element into which this tag is to be rendered. 635 * @param {function(string,TagAPI)=} success 636 * A function that will be called once the tag is rendered. 637 * @param {function(GCNError):boolean=} error 638 * A custom error handler. 639 * @param {boolean} post 640 * True, when the tag shall be rendered by POSTing the data to 641 * the REST API. Otherwise the tag is rendered with a GET call 642 */ 643 edit: function () { 644 var tag = this; 645 var args = getRenderOptions(arguments); 646 if (args.data) { 647 648 // Because the parent content object needs to track any 649 // blocks or editables that have been rendered in this tag. 650 tag.parent()._processRenderedTags(args.data); 651 652 // Because 'rendered-for-editing' is deprecated in favor of 653 // 'tag.rendered-for-editing' 654 GCN.pub('rendered-for-editing', { 655 tag: tag, 656 data: args.data, 657 callback: function () { 658 if (args.success) { 659 tag._invoke( 660 args.success, 661 [args.content, tag, args.data] 662 ); 663 } 664 } 665 }); 666 GCN.pub('tag.rendered-for-editing', { 667 tag: tag, 668 data: args.data, 669 callback: function () { 670 if (args.success) { 671 tag._invoke( 672 args.success, 673 [args.content, tag, args.data] 674 ); 675 } 676 } 677 }); 678 } else { 679 jQuery(function () { 680 if (args.element || args.success) { 681 tag._render( 682 'edit', 683 args.element, 684 args.success, 685 args.error, 686 args.post 687 ); 688 } 689 }); 690 } 691 }, 692 693 /** 694 * Persists the changes to this tag on its container object. Will only 695 * save this one tag and not affect the container object itself. 696 * Important: be careful when dealing with editable contents - these 697 * will be reloaded from Aloha Editor editables when a page is saved 698 * and thus overwrite changes you made to an editable tag. 699 * 700 * @function 701 * @name save 702 * @memberOf TagAPI 703 * @param {object=} settings Optional settings to pass on to the ajax 704 * function. 705 * @param {function(TagAPI)} success Callback to be invoked when this 706 * operation completes normally. 707 * @param {function(GCNError):boolean} error Custom error handler. 708 */ 709 save: function (settings, success, error) { 710 var tag = this; 711 var parent = tag.parent(); 712 var type = parent._type; 713 // to support the optional setting object as first argument we need 714 // to shift the arguments when it is not an object 715 if (jQuery.type(settings) !== 'object') { 716 error = success; 717 success = settings; 718 settings = null; 719 } 720 var json = settings || {}; 721 // create a mockup object to be able to save only one tag 722 // id is needed - REST API won't accept objects without id 723 json[type] = { id: parent.id(), tags: {} }; 724 json[type].tags[tag._name] = tag._data; 725 726 parent._authAjax({ 727 url : GCN.settings.BACKEND_PATH + '/rest/' + type + '/save/' 728 + parent.id() + GCN._getChannelParameter(parent), 729 type : 'POST', 730 error : error, 731 json : json, 732 success : function onTagSaveSuccess(response) { 733 if (GCN.getResponseCode(response) === 'OK') { 734 tag._invoke(success, [tag]); 735 } else { 736 tag._die(GCN.getResponseCode(response)); 737 GCN.handleResponseError(response, error); 738 } 739 } 740 }); 741 }, 742 743 /** 744 * TagAPI Objects are not cached themselves. Their _data object 745 * always references a tag in the _data of their parent, so that 746 * changes made to the TagAPI object will also change the tag in the 747 * _data of the parent. 748 * If the parent is reloaded and the _data refreshed, this would not 749 * clear or refresh the cache of the TagAPI objects. This would lead 750 * to a "broken" references and changes made to the cached TagAPI object 751 * would no longer change the tag in the parent. 752 * 753 * @private 754 * @return {Chainback} This Chainback. 755 */ 756 _addToCache: function () { 757 return this; 758 } 759 }); 760 761 // Unlike content objects, tags do not have unique ids and so we uniquely I 762 // dentify tags by their name, and their parent's id. 763 TagAPI._needsChainedHash = true; 764 765 GCN.tag = GCN.exposeAPI(TagAPI); 766 GCN.TagAPI = TagAPI; 767 768 }(GCN)); 769