API Docs for:
Show:

File: components/TicketView/TicketView.js

/** @jsx React.DOM */
"use strict";
var React = require("react/addons");
var Router = require("react-router");
var classSet = React.addons.classSet;
var _ = require("lodash");
var Promise = require("bluebird");
var debug = require("debug")("app:read");

var Button = require("react-bootstrap/Button");
var Badge = require("react-bootstrap/Badge");
var Alert = require("react-bootstrap/Alert");

var Loading = require("../Loading");
var CommentForm = require("../CommentForm");
var AttachmentsForm = require("../AttachmentsForm");
var captureError = require("../../utils/captureError");
var BackboneMixin = require("../../components/BackboneMixin");
var Ticket = require("../../models/client/Ticket");
var User = require("../../models/client/User");
var Loading = require("../Loading");
var SelectUsers = require("../SelectUsers");
var SideInfo = require("../SideInfo");
var Redacted = require("../Redacted");
var EditableText = require("../EditableText");
var BrowserTitle = require("app/utils/BrowserTitle");
var UploadProgress = require("app/components/UploadProgress");

var ToggleStatusButton = require("./ToggleStatusButton");
var ToggleFollowButton = require("./ToggleFollowButton");
var CommentUpdate = require("./CommentUpdate");


// Individual components for each ticket update type
var UPDATE_COMPONENTS = {
    comments: CommentUpdate,
    tags: require("./TagUpdate"),
    handlers: require("./HandlerUpdate"),
    titles: require("./TitleUpdate")
};



/**
 * TicketView
 *
 * @namespace components
 * @class TicketView
 * @constructor
 * @param {Object} props
 * @param {models.client.User} props.user
 * @param {Socket.IO} props.io Socket.IO socket
 * @param {Function} props.renderInModal
 * @param {BrowserTitle} props.title BrowserTitle instance
 */
var TicketView = React.createClass({

    mixins: [BackboneMixin],

    propTypes: {
        title: React.PropTypes.instanceOf(BrowserTitle).isRequired,
        user: React.PropTypes.instanceOf(User).isRequired,
        renderInModal: React.PropTypes.func.isRequired,
        io: React.PropTypes.shape({
            on: React.PropTypes.func.isRequired,
            off: React.PropTypes.func.isRequired
        }).isRequired
    },

    createInitialState: function(props) {
        return {
            ticket: new Ticket({ id: props.params.id }),
            changeTitle: false,
            fetching: true,
            saving: false,
            showTags: true,
            scrolled: false,
            uploadProgress: null
        };
    },

    getInitialState: function() {
        return this.createInitialState(this.props);
    },

    componentWillReceiveProps: function(nextProps) {
        if (this.props.params.id === nextProps.params.id) return;
        this.setBackbone(this.createInitialState(nextProps), this.fetchTicket);
    },

    /**
     * Called when a new live comment message is received from socket.io
     *
     * @private
     * @method _handleWatcherUpdate
     */
    _handleWatcherUpdate: function(comment) {
        if (comment.ticketId === this.state.ticket.get("id")) {
            this.fetchTicket();
        }
    },

    /**
     * Subscribe to the ticket updates
     *
     * @method startWatching
     */
    startWatching: function() {
        this.props.io.emit("startWatching", {
            ticketId: this.state.ticket.get("id")
        });
    },

    /**
     * Called when socket reconnects while the user is still watching it
     *
     * Restarts watching and refreshes the ticket
     *
     * @private
     * @method _handleSocketConnect
     */
    _handleSocketConnect: function() {
        this.startWatching();
        this.fetchTicket();
    },

    componentDidMount: function() {
        window.scrollTo(0, 0);
        this.fetchTicket();
        window.addEventListener("focus", this.handleOnFocus);
        this.props.io.on("watcherUpdate", this._handleWatcherUpdate);
        this.startWatching();

        this.props.io.on("connect", this._handleSocketConnect);

        /**
         * Lazy version of the `markAsRead()` method. It will mark the ticket
         * as read at max once in 10 seconds
         *
         * @method lazyMarkAsRead
         */
        this.lazyMarkAsRead = _.throttle(this.markAsRead, 5*1000);
    },

    componentWillUnmount: function() {
        window.removeEventListener("focus", this.handleOnFocus);
        this.props.io.emit("stopWatching", {
            ticketId: this.state.ticket.get("id")
        });
        this.props.io.off("watcherUpdate", this._handleWatcherUpdate);
        this.props.io.off("connect", this._handleSocketConnect);
        this.props.title.setTitle("");
        this.props.title.activateOnNextTick();
    },

    componentDidUpdate: function() {
        this.scrollToAnchoredElement();
    },


    /**
     * Anchor links (eg. #foobar) does not work on dynamically loaded elements
     * because they are not present at load time. This method manually scrolls
     * to the linked element when they appear.
     *
     * @method scrollToAnchoredElement
     */
    scrollToAnchoredElement: function() {
        var unread = this.state.ticket.firstUnreadUpdateFor(this.props.user);

        // Remove ?scrollTo=firstUnread query string and set
        // window.location.hash
        if (unread && this.props.query.scrollTo === "firstUnread") {
            Router.replaceWith(this.props.name, this.props.params);
            window.location.hash = unread.getUniqueId();
        }

        // Nothing selected
        if (!window.location.hash) return;

        // No need to scroll multiple times
        if (this.state.scrolled) return;

        var el = document.getElementById(window.location.hash.slice(1));
        // Element not rendered yet - or it just does not exists
        if (!el) return;

        el.scrollIntoView();
        this.setState({ scrolled: true });
    },

    /**
     * Save comment handler. Reports any unhandled errors to the global error
     * handler
     *
     * @method saveComment
     */
    saveComment: function(e) {
        var self = this;
        e.clear();
        self.setState({ saving: true });

        self.state.ticket.addComment(e.comment)
        .then(function(comment) {
            var files = self.refs.attachments.getFiles();
            if (files.length > 0) {
                self.refs.attachments.clear();
                return comment.addAttachments(files, { onProgress: function(e) {
                    self.setState({ uploadProgress: e });
                }});
            }
        })
        .then(function() {
            self.setState({ uploadProgress: null });
            return self.fetchTicket();
        })
        .then(function() {
            if (!self.isMounted()) return;
            self.setState({ saving: false });
            process.nextTick(e.scrollToCommentButton);
        })
        .catch(captureError("Kommentin tallennus epäonnistui"));

    },


    handleClose: function() {
        this.state.ticket.close();
    },


    toggleTags: function() {
        this.setState({
            showTags: !this.state.showTags
        });
    },

    handleAddHandler: function() {
        var self = this;
        self.props.renderInModal("Lisää käsittelijöitä", function(close){
            return (
                <SelectUsers
                    user={self.props.user}
                    ticket={self.state.ticket}
                    currentHandlers={_.invoke(self.state.ticket.handlers(), "getUser")}
                    onCancel={close}
                    onSelect={function(users) {
                        close();
                        if (self.isMounted()) self.setState({ fetching: true });

                        Promise.map(users, function(user) {
                            return self.state.ticket.addHandler(user);
                        })
                        .then(function() {
                            return self.fetchTicket();
                        })
                        .catch(captureError("Käsittelijöiden lisääminen epäonnistui"));
                }}/>
            );
        });
    },

    handleOnFocus: function() {
        this.fetchTicket();
    },

    /**
     * Mark the ticket as read by the current user and refetch the ticket data
     *
     * @method markAsRead
     * @return {Bluebird.Promise}
     */
    markAsRead: function() {
        if (!this.isMounted()) return;

        this.setState({ fetching: true });
        debug("Actually marking as read");
        return this.state.ticket.markAsRead()
            .bind(this)
            .then(function() {
                return this.fetchTicket();
            })
            .catch(captureError("Tukipyynnön merkkaaminen luetuksi epäonnistui"));
    },


    /**
     * Fetch the ticket data
     *
     * @method fetchTicket
     * @return {Bluebird.Promise}
     */
    fetchTicket: function() {
        if (!this.isMounted()) return;

        this.setState({ fetching: true });
        return this.state.ticket.fetch()
            .bind(this)
            .then(function() {
                if (this.isMounted()) this.setState({ fetching: false });
            })
            .catch(Ticket.NotFound, function(err) {
                this.setState({
                    notFound: err
                });
            })
            .catch(captureError("Tukipyynnön tilan päivitys epäonnistui"));
    },



    renderBadge: function() {

        var id = "#" + this.state.ticket.get("id");

        var status = this.state.ticket.getCurrentStatus();
        switch (status) {
            case "open":
                return <Badge className="ticket-status ticket-open">Avoin {id}</Badge>;
            case "closed":
                return <Badge className="ticket-status ticket-closed">Ratkaistu {id}</Badge>;
            default:
                return <Badge className="ticket-status"> <Redacted>Unknown</Redacted></Badge>;
        }
    },

    renderDate: function() {
        var datestring = this.state.ticket.get("createdAt"),
        options={weekday: "long", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute:"numeric"};
        return(
            <span className="badge-text">
                <time dateTime={'"' + datestring + '"'} />{" " + new Date(Date.parse(datestring)).toLocaleString('fi', options)}
            </span>
        );
    },

    changeTitle: function(e) {
        this.setState({ changingTitle: true });
        this.state.ticket.addTitle(e.value)
        .delay(2000)
        .bind(this)
        .then(function() {
            if (!this.isMounted()) return;
            return this.fetchTicket();
        })
        .then(function() {
            if (!this.isMounted()) return;
            this.setState({ changingTitle: false });
        })
        .catch(captureError("Otsikon päivitys epäonnistui"));
    },


    /**
     * Get array of updates with comments merged that are created by the same
     * user within small amount of time
     *
     * @method getUpdatesWithMergedComments
     * @return {Array} of models.client.Base
     */
    getUpdatesWithMergedComments: function(){
        return this.state.ticket.updates().reduce(function(a, next) {
            var prev = a.pop();
            if (!prev) {
                a.push(next);
                return a;
            }

            var bothComments = (
                prev.get("type") === "comments" &&
                next.get("type") === "comments"
            );

            if (bothComments && prev.wasCreatedInVicinityOf(next)) {
                a.push(prev.merge(next));
                return a;
            }

            a.push(prev);
            a.push(next);
            return a;

        }, []);
    },

    render: function() {
        if (this.state.notFound) {
            return <Alert bsStyle="danger">
                Hakemaasi tukipyyntöä ei ole olemassa.
            </Alert>;
        }


        var self = this;
        var ticket = this.state.ticket;
        var fetching = this.state.fetching;
        var user = this.props.user;
        var updates = this.getUpdatesWithMergedComments();
        var title = ticket.getCurrentTitle();

        this.props.title.setTitle(title);
        this.props.title.activateOnNextTick();

        return (
            <div className="row TicketView">

                <div className="ticket-view col-md-8">

                    <Loading visible={fetching} />

                    <div className="row ticket-actions-row">
                        <div className="col-md-12">
                            {user.isManager() &&
                                <Button bsStyle="primary" onClick={this.handleAddHandler} >
                                    <i className="fa fa-user"></i>Lisää käsittelijä
                                </Button> }

                            {ticket.isHandler(user) &&
                                <ToggleStatusButton ticket={ticket} user={user} />}


                            {ticket.createdBy().get("id") !== user.get("id") &&
                                <ToggleFollowButton ticket={ticket} user={user} />}
                        </div>
                    </div>

                    <div className="row title-row">
                        <div className="col-md-12">
                            <EditableText onSubmit={this.changeTitle} text={title} disabled={!ticket.isHandler(user)}>
                                <h3>
                                    {this.renderBadge()}

                                    <span className="ticket-title">
                                        {title || <Redacted>Ladataan otsikkoa</Redacted>}
                                    </span>
                                    {this.state.changingTitle && <Loading.Spinner />}
                                </h3>
                            </EditableText>
                        </div>
                    </div>

                    <div className="updates">
                        {updates.map(function(update) {
                            var UpdateComponent = UPDATE_COMPONENTS[update.get("type")];

                            if (!UpdateComponent) {
                                console.error("Unknown update type: " + update.get("type"));
                                return;
                            }

                            var className = classSet({
                                unread: update.isUnreadBy(self.props.user)
                            });


                            return (
                                <div key={update.getUniqueId()} className={className}>
                                    <UpdateComponent update={update} onViewport={function(props) {
                                        if (_.last(updates) !== props.update) return;
                                        // Mark the ticket as read 5 seconds
                                        // after the last update has been shown
                                        // to the user
                                        debug("Last comment is visible. Going to mark it read soon!");
                                        setTimeout(function() {
                                            self.lazyMarkAsRead();
                                        }, 5*1000);
                                    }} />
                                </div>
                            );
                        })}
                    </div>

                    <CommentForm onSubmit={this.saveComment} >
                        Lähetä {this.state.saving && <Loading.Spinner />}
                    </CommentForm>
                    <UploadProgress progress={this.state.uploadProgress} />
                    <AttachmentsForm ref="attachments" />
                </div>

                <div className="col-md-4">
                    <SideInfo />
                </div>
            </div>
        );
    },

});

module.exports = TicketView;