diff options
Diffstat (limited to 'comm/calendar/base/content/calendar-task-tree-view.js')
-rw-r--r-- | comm/calendar/base/content/calendar-task-tree-view.js | 495 |
1 files changed, 495 insertions, 0 deletions
diff --git a/comm/calendar/base/content/calendar-task-tree-view.js b/comm/calendar/base/content/calendar-task-tree-view.js new file mode 100644 index 0000000000..ebe258419f --- /dev/null +++ b/comm/calendar/base/content/calendar-task-tree-view.js @@ -0,0 +1,495 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* exported CalendarTaskTreeView */ + +/* import-globals-from item-editing/calendar-item-editing.js */ +/* import-globals-from widgets/mouseoverPreviews.js */ + +/* globals cal */ + +/** + * The tree view for a CalendarTaskTree. + */ +class CalendarTaskTreeView { + /** + * Creates a new task tree view and connects it to a given task tree. + * + * @param {CalendarTaskTree} taskTree - The task tree to connect the view to. + */ + constructor(taskTree) { + this.tree = taskTree; + this.mSelectedColumn = null; + this.sortDirection = null; + } + + QueryInterface = ChromeUtils.generateQI(["nsITreeView"]); + + /** + * Get the selected column. + * + * @returns {Element} A treecol element. + */ + get selectedColumn() { + return this.mSelectedColumn; + } + + /** + * Set the selected column and sort by that column. + * + * @param {Element} column - A treecol element. + */ + set selectedColumn(column) { + const columnProperty = column.getAttribute("itemproperty"); + + this.tree.querySelectorAll("treecol").forEach(col => { + if (col.getAttribute("sortActive")) { + col.removeAttribute("sortActive"); + col.removeAttribute("sortDirection"); + } + if (columnProperty == col.getAttribute("itemproperty")) { + col.setAttribute("sortActive", "true"); + col.setAttribute("sortDirection", this.sortDirection); + } + }); + this.mSelectedColumn = column; + } + + // High-level task tree manipulation + + /** + * Adds an array of items (tasks) to the list if they match the currently applied filter. + * + * @param {object[]} items - An array of task objects to add. + * @param {boolean} [doNotSort] - Whether to re-sort after adding the tasks. + */ + addItems(items, doNotSort) { + this.modifyItems(items, [], doNotSort, true); + } + /** + * Removes an array of items (tasks) from the list. + * + * @param {object[]} items - An array of task objects to remove. + */ + removeItems(items) { + this.modifyItems([], items, true, false); + } + + /** + * Removes an array of old items from the list, and adds an array of new items if + * they match the currently applied filter. + * + * @param {object[]} newItems - An array of new items to add. + * @param {object[]} oldItems - An array of old items to remove. + * @param {boolean} [doNotSort] - Whether to re-sort the list after modifying it. + * @param {boolean} [selectNew] - Whether to select the new tasks. + */ + modifyItems(newItems = [], oldItems = [], doNotSort, selectNew) { + let selItem = this.tree.currentTask; + let selIndex = this.tree.currentIndex; + let firstHash = null; + let remIndexes = []; + + this.tree.beginUpdateBatch(); + + let idiff = new cal.item.ItemDiff(); + idiff.load(oldItems); + idiff.difference(newItems); + idiff.complete(); + let delItems = idiff.deletedItems; + let addItems = idiff.addedItems; + let modItems = idiff.modifiedItems; + + // Find the indexes of the old items that need to be removed. + for (let item of delItems.mArray) { + if (item.hashId in this.tree.mHash2Index) { + // The old item needs to be removed. + remIndexes.push(this.tree.mHash2Index[item.hashId]); + delete this.tree.mHash2Index[item.hashId]; + } + } + + // Modified items need to be updated. + for (let item of modItems.mArray) { + if (item.hashId in this.tree.mHash2Index) { + // Make sure we're using the new version of a modified item. + this.tree.mTaskArray[this.tree.mHash2Index[item.hashId]] = item; + } + } + + // Remove the old items working backward from the end so the indexes stay valid. + remIndexes + .sort((a, b) => b - a) + .forEach(index => { + this.tree.mTaskArray.splice(index, 1); + this.tree.rowCountChanged(index, -1); + }); + + // Add the new items. + for (let item of addItems.mArray) { + if (!(item.hashId in this.tree.mHash2Index)) { + let index = this.tree.mTaskArray.length; + this.tree.mTaskArray.push(item); + this.tree.mHash2Index[item.hashId] = index; + this.tree.rowCountChanged(index, 1); + firstHash = firstHash || item.hashId; + } + } + + if (doNotSort) { + this.tree.recreateHashTable(); + } else { + this.tree.sortItems(); + } + + if (selectNew && firstHash && firstHash in this.tree.mHash2Index) { + // Select the first item added into the list. + selIndex = this.tree.mHash2Index[firstHash]; + } else if (selItem && selItem.hashId in this.tree.mHash2Index) { + // Select the previously selected item. + selIndex = this.tree.mHash2Index[selItem.hashId]; + } else if (selIndex >= this.tree.mTaskArray.length) { + // Make sure the previously selected index is valid. + selIndex = this.tree.mTaskArray.length - 1; + } + + if (selIndex > -1) { + this.tree.view.selection.select(selIndex); + this.tree.ensureRowIsVisible(selIndex); + } + + this.tree.endUpdateBatch(); + } + + /** + * Remove all tasks from the list/tree. + */ + clear() { + let count = this.tree.mTaskArray.length; + if (count > 0) { + this.tree.mTaskArray = []; + this.tree.mHash2Index = {}; + this.tree.rowCountChanged(0, -count); + this.tree.view.selection.clearSelection(); + } + } + + /** + * Refresh the display for a given task. + * + * @param {object} item - The task object to refresh. + */ + updateItem(item) { + let index = this.tree.mHash2Index[item.hashId]; + if (index) { + this.tree.invalidateRow(index); + } + } + + /** + * Return the item (task) object that's related to a given event. If passed a column and/or row + * object, set their 'value' property to the column and/or row related to the event. + * + * @param {Event} event - An event. + * @param {object} [col] - A column object. + * @param {object} [row] - A row object. + * @returns {object | false} The task object related to the event or false if none found. + */ + getItemFromEvent(event, col, row) { + let { col: eventColumn, row: eventRow } = this.tree.getCellAt(event.clientX, event.clientY); + if (col) { + col.value = eventColumn; + } + if (row) { + row.value = eventRow; + } + return eventRow > -1 && this.tree.mTaskArray[eventRow]; + } + + // nsITreeView Methods and Properties + + get rowCount() { + return this.tree.mTaskArray.length; + } + + getCellProperties(row, col) { + let rowProps = this.getRowProperties(row); + let colProps = this.getColumnProperties(col); + return rowProps + (rowProps && colProps ? " " : "") + colProps; + } + + getColumnProperties(col) { + return col.element.getAttribute("id") || ""; + } + + getRowProperties(row) { + let properties = []; + let item = this.tree.mTaskArray[row]; + if (item.priority > 0 && item.priority < 5) { + properties.push("highpriority"); + } else if (item.priority > 5 && item.priority < 10) { + properties.push("lowpriority"); + } + properties.push(cal.item.getProgressAtom(item)); + + // Add calendar name and id atom. + properties.push("calendar-" + cal.view.formatStringForCSSRule(item.calendar.name)); + properties.push("calendarid-" + cal.view.formatStringForCSSRule(item.calendar.id)); + + // Add item status atom. + if (item.status) { + properties.push("status-" + item.status.toLowerCase()); + } + + // Alarm status atom. + if (item.getAlarms().length) { + properties.push("alarm"); + } + + // Task categories. + properties = properties.concat(item.getCategories().map(cal.view.formatStringForCSSRule)); + + return properties.join(" "); + } + + cycleCell(row, col) { + let task = this.tree.mTaskArray[row]; + + // Prevent toggling completed status for parent items of + // repeating tasks or when the calendar is read-only. + if (!task || task.recurrenceInfo || task.calendar.readOnly) { + return; + } + if (col != null) { + let content = col.element.getAttribute("itemproperty"); + if (content == "completed") { + let newTask = task.clone().QueryInterface(Ci.calITodo); + newTask.isCompleted = !task.completedDate; + doTransaction("modify", newTask, newTask.calendar, task, null); + } + } + } + + cycleHeader(col) { + if (!this.selectedColumn) { + this.sortDirection = "ascending"; + } else if (!this.sortDirection || this.sortDirection == "descending") { + this.sortDirection = "ascending"; + } else { + this.sortDirection = "descending"; + } + this.selectedColumn = col.element; + let selectedItems = this.tree.selectedTasks; + this.tree.sortItems(); + if (selectedItems != undefined) { + this.tree.view.selection.clearSelection(); + for (let item of selectedItems) { + let index = this.tree.mHash2Index[item.hashId]; + this.tree.view.selection.toggleSelect(index); + } + } + } + + getCellText(row, col) { + let task = this.tree.mTaskArray[row]; + if (!task) { + return ""; + } + + const property = col.element.getAttribute("itemproperty"); + switch (property) { + case "title": + // Return title, or "Untitled" if empty/null. + return task.title ? task.title.replace(/\n/g, " ") : cal.l10n.getCalString("eventUntitled"); + case "entryDate": + case "dueDate": + case "completedDate": + return task.recurrenceInfo + ? cal.l10n.getDateFmtString("Repeating") + : this._formatDateTime(task[property]); + case "percentComplete": + return task.percentComplete > 0 ? task.percentComplete + "%" : ""; + case "categories": + // TODO This is l10n-unfriendly. + return task.getCategories().join(", "); + case "location": + return task.getProperty("LOCATION"); + case "status": + return getToDoStatusString(task); + case "calendar": + return task.calendar.name; + case "duration": + return this.tree.duration(task); + case "completed": + case "priority": + default: + return ""; + } + } + + getCellValue(row, col) { + let task = this.tree.mTaskArray[row]; + if (!task) { + return null; + } + switch (col.element.getAttribute("itemproperty")) { + case "percentComplete": + return task.percentComplete; + } + return null; + } + + setCellValue(row, col, value) { + return null; + } + + getImageSrc(row, col) { + return ""; + } + + isEditable(row, col) { + return true; + } + + /** + * Called to link the task tree to the tree view. A null argument un-sets/un-links the tree. + * + * @param {object | null} tree + */ + setTree(tree) { + const hasOldTree = this.tree != null; + if (hasOldTree && !tree) { + // Balances the addObserver calls from the refresh method in the tree. + + // Remove the composite calendar observer. + const composite = cal.view.getCompositeCalendar(window); + composite.removeObserver(this.tree.mTaskTreeObserver); + + // Remove the preference observer. + const branch = Services.prefs.getBranch(""); + branch.removeObserver("calendar.", this.tree.mPrefObserver); + } + this.tree = tree; + } + + isContainer(row) { + return false; + } + isContainerOpen(row) { + return false; + } + isContainerEmpty(row) { + return false; + } + + isSeparator(row) { + return false; + } + + isSorted(row) { + return false; + } + + canDrop() { + return false; + } + + drop(row, orientation) {} + + getParentIndex(row) { + return -1; + } + + getLevel(row) { + return 0; + } + + // End nsITreeView Methods and Properties + // Task Tree Event Handlers + + onSelect(event) {} + + /** + * Handle double click events. + * + * @param {Event} event - The double click event. + */ + onDoubleClick(event) { + // Only handle left mouse button clicks. + if (event.button != 0) { + return; + } + const initialDate = cal.dtz.getDefaultStartDate(this.tree.getInitialDate()); + const col = {}; + const item = this.getItemFromEvent(event, col); + if (item) { + const itemProperty = col.value.element.getAttribute("itemproperty"); + + // If itemProperty == "completed" then the user has clicked a "completed" checkbox + // and `item` holds the checkbox state toggled by the first click. So, to make sure the + // user notices that the state changed, don't call modifyEventWithDialog. + if (itemProperty != "completed") { + modifyEventWithDialog(item, true, initialDate); + } + } else { + createTodoWithDialog(null, null, null, null, initialDate); + } + } + + /** + * Handle key press events. + * + * @param {Event} event - The key press event. + */ + onKeyPress(event) { + switch (event.key) { + case "Delete": { + event.target.triggerNode = this.tree; + document.getElementById("calendar_delete_todo_command").doCommand(); + event.preventDefault(); + event.stopPropagation(); + break; + } + case " ": { + if (this.tree.currentIndex > -1) { + let col = this.tree.querySelector("[itemproperty='completed']"); + this.cycleCell(this.tree.currentIndex, { element: col }); + } + break; + } + case "Enter": { + let index = this.tree.currentIndex; + if (index > -1) { + modifyEventWithDialog(this.tree.mTaskArray[index]); + } + break; + } + } + } + + /** + * Set the context menu on mousedown to change it before it is opened. + * + * @param {Event} event - The mousedown event. + */ + onMouseDown(event) { + if (!this.getItemFromEvent(event)) { + this.tree.view.selection.invalidateSelection(); + } + } + + // Private Methods and Attributes + + /** + * Format a datetime object for display. + * + * @param {object} dateTime - From a todo object, not a JavaScript date. + * @returns {string} Formatted string version of the datetime ("" if invalid). + */ + _formatDateTime(dateTime) { + return dateTime && dateTime.isValid + ? cal.dtz.formatter.formatDateTime(dateTime.getInTimezone(cal.dtz.defaultTimezone)) + : ""; + } +} |