diff --git a/src/Calendars/Main.tsx b/src/Calendars/Main.tsx index 280c3ede..f8678ab8 100644 --- a/src/Calendars/Main.tsx +++ b/src/Calendars/Main.tsx @@ -70,12 +70,17 @@ export default function CalendarsMain() { async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise { const collection = collections!.find((x) => x.uid === collectionUid)!; - await itemSave(etebase, collection, items!, item, collectionUid, originalItem); + await itemSave(etebase, collection, items!, collectionUid, + [{ + original: originalItem, + new: item, + }] + ); } async function onItemDelete(item: PimType, collectionUid: string) { const collection = collections!.find((x) => x.uid === collectionUid)!; - await itemDelete(etebase, collection, items!, item, collectionUid); + await itemDelete(etebase, collection, items!, [item], collectionUid); history.push(routeResolver.getRoute("pim.events")); } diff --git a/src/Contacts/Main.tsx b/src/Contacts/Main.tsx index 478bf022..13a114e2 100644 --- a/src/Contacts/Main.tsx +++ b/src/Contacts/Main.tsx @@ -59,12 +59,17 @@ export default function ContactsMain() { async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise { const collection = collections!.find((x) => x.uid === collectionUid)!; - await itemSave(etebase, collection, items!, item, collectionUid, originalItem); + await itemSave(etebase, collection, items!, collectionUid, + [{ + original: originalItem, + new: item, + }] + ); } async function onItemDelete(item: PimType, collectionUid: string) { const collection = collections!.find((x) => x.uid === collectionUid)!; - await itemDelete(etebase, collection, items!, item, collectionUid); + await itemDelete(etebase, collection, items!, [item], collectionUid); history.push(routeResolver.getRoute("pim.contacts")); } diff --git a/src/Pim/helpers.tsx b/src/Pim/helpers.tsx index 012573df..7b90ce66 100644 --- a/src/Pim/helpers.tsx +++ b/src/Pim/helpers.tsx @@ -9,7 +9,7 @@ import memoize from "memoizee"; import * as Etebase from "etebase"; -import { PimType } from "../pim-types"; +import { PimChanges, PimType } from "../pim-types"; import { getCollectionManager } from "../etebase-helpers"; import { asyncDispatch, store } from "../store"; import { itemBatch, appendError } from "../store/actions"; @@ -85,47 +85,52 @@ export function getDecryptItemsFunction(_colType: string, par ); } -export async function itemSave(etebase: Etebase.Account, collection: Etebase.Collection, items: Map>, item: PimType, collectionUid: string, originalItem?: PimType): Promise { - const itemUid = originalItem?.itemUid; +export async function itemSave(etebase: Etebase.Account, collection: Etebase.Collection, items: Map>, collectionUid: string, changes: PimChanges[]): Promise { const colMgr = getCollectionManager(etebase); const itemMgr = colMgr.getItemManager(collection); - const mtime = (new Date()).getTime(); - const content = item.toIcal(); - - let eteItem; - if (itemUid) { - // Existing item - eteItem = items!.get(collectionUid)?.get(itemUid)!; - await eteItem.setContent(content); - const meta = eteItem.getMeta(); - meta.mtime = mtime; - eteItem.setMeta(meta); - } else { - // New - const meta: Etebase.ItemMetadata = { - mtime, - name: item.uid, - }; - eteItem = await itemMgr.create(meta, content); + const itemList = []; + for (const item of changes) { + const itemUid = item.original?.itemUid; + const content = item.new.toIcal(); + let eteItem; + if (itemUid) { + // Existing item + eteItem = items!.get(collectionUid)?.get(itemUid)!; + await eteItem.setContent(content); + const meta = eteItem.getMeta(); + meta.mtime = mtime; + eteItem.setMeta(meta); + } else { + // New + const meta: Etebase.ItemMetadata = { + mtime, + name: item.new.uid, + }; + eteItem = await itemMgr.create(meta, content); + } + itemList.push(eteItem); } - - await asyncDispatch(itemBatch(collection, itemMgr, [eteItem])); + await asyncDispatch(itemBatch(collection, itemMgr, itemList)); } -export async function itemDelete(etebase: Etebase.Account, collection: Etebase.Collection, items: Map>, item: PimType, collectionUid: string) { - const itemUid = item.itemUid!; +export async function itemDelete(etebase: Etebase.Account, collection: Etebase.Collection, items: Map>, itemsToDelete: PimType[], collectionUid: string) { const colMgr = getCollectionManager(etebase); const itemMgr = colMgr.getItemManager(collection); + const itemList = []; + for (const item of itemsToDelete) { + const itemUid = item.itemUid!; + const eteItem = items!.get(collectionUid)?.get(itemUid)!; + const mtime = (new Date()).getTime(); + const meta = eteItem.getMeta(); + meta.mtime = mtime; + eteItem.setMeta(meta); + eteItem.delete(true); + itemList.push(eteItem); + } + - const eteItem = items!.get(collectionUid)?.get(itemUid)!; - const mtime = (new Date()).getTime(); - const meta = eteItem.getMeta(); - meta.mtime = mtime; - eteItem.setMeta(meta); - eteItem.delete(true); - - await asyncDispatch(itemBatch(collection, itemMgr, [eteItem])); + await asyncDispatch(itemBatch(collection, itemMgr, itemList)); } interface PimFabPropsType { diff --git a/src/Tasks/Main.tsx b/src/Tasks/Main.tsx index 3823ffa3..052bff55 100644 --- a/src/Tasks/Main.tsx +++ b/src/Tasks/Main.tsx @@ -10,7 +10,7 @@ import { Button, useTheme } from "@material-ui/core"; import IconEdit from "@material-ui/icons/Edit"; import IconChangeHistory from "@material-ui/icons/ChangeHistory"; -import { TaskType, PimType } from "../pim-types"; +import { TaskType, PimType, PimChanges } from "../pim-types"; import { useCredentials } from "../credentials"; import { useItems, useCollections } from "../etebase-helpers"; import { routeResolver } from "../App"; @@ -57,15 +57,15 @@ export default function TasksMain() { } async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise { - const collection = collections!.find((x) => x.uid === collectionUid)!; - await itemSave(etebase, collection, items!, item, collectionUid, originalItem); + await onMultipleItemsSave([{ + original: originalItem, + new: item, + }], collectionUid); } - async function onItemDelete(item: PimType, collectionUid: string) { + async function onMultipleItemsSave(changes: PimChanges[], collectionUid: string): Promise { const collection = collections!.find((x) => x.uid === collectionUid)!; - await itemDelete(etebase, collection, items!, item, collectionUid); - - history.push(routeResolver.getRoute("pim.tasks")); + await itemSave(etebase, collection, items!, collectionUid, changes); } function onCancel() { @@ -79,6 +79,25 @@ export default function TasksMain() { } } + async function onItemDelete(item: PimType, collectionUid: string, redirect = true, recursive = false) { + const collection = collections!.find((x) => x.uid === collectionUid)!; + if (recursive) { + let index = 0; + const deleteTarget = [item]; + while (index < deleteTarget.length) { + const current = deleteTarget[index++]; + const children = flatEntries.filter((i) => i.relatedTo === current.uid); + deleteTarget.push(...children); + } + await itemDelete(etebase, collection, items!, deleteTarget, collectionUid); + } else { + await itemDelete(etebase, collection, items!, [item], collectionUid); + } + if (redirect) { + history.push(routeResolver.getRoute("pim.tasks")); + } + } + const styles = { button: { marginLeft: theme.spacing(1), @@ -113,9 +132,10 @@ export default function TasksMain() { exact > t.relatedTo === item.uid)} entries={flatEntries} key={itemUid} initialCollection={item.collectionUid} item={item} collections={cachedCollections} - onSave={onItemSave} + onSave={onMultipleItemsSave} onDelete={onItemDelete} onCancel={onCancel} history={history} diff --git a/src/Tasks/TaskEdit.tsx b/src/Tasks/TaskEdit.tsx index 32d19539..6fe35d67 100644 --- a/src/Tasks/TaskEdit.tsx +++ b/src/Tasks/TaskEdit.tsx @@ -17,9 +17,19 @@ import InputLabel from "@material-ui/core/InputLabel"; import * as colors from "@material-ui/core/colors"; import FormLabel from "@material-ui/core/FormLabel"; import RadioGroup from "@material-ui/core/RadioGroup"; +import Checkbox from "@material-ui/core/Checkbox"; +import Grid from "@material-ui/core/Grid"; +import IconButton from "@material-ui/core/IconButton" +import InputAdornment from "@material-ui/core/InputAdornment"; +import List from "@material-ui/core/List"; +import ListItem from "@material-ui/core/ListItem"; +import ListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction"; +import ListItemText from "@material-ui/core/ListItemText"; +import OutlinedInput from "@material-ui/core/OutlinedInput"; import Autocomplete from "@material-ui/lab/Autocomplete"; +import IconAdd from "@material-ui/icons/Add"; import IconDelete from "@material-ui/icons/Delete"; import IconCancel from "@material-ui/icons/Clear"; import IconSave from "@material-ui/icons/Save"; @@ -35,7 +45,7 @@ import * as ICAL from "ical.js"; import { getCurrentTimezone, mapPriority } from "../helpers"; -import { TaskType, TaskStatusType, timezoneLoadFromName, TaskPriorityType, TaskTags } from "../pim-types"; +import { TaskType, TaskStatusType, timezoneLoadFromName, TaskPriorityType, TaskTags, PimChanges } from "../pim-types"; import { History } from "history"; @@ -47,10 +57,11 @@ import TaskSelector from "./TaskSelector"; interface PropsType { entries: TaskType[]; collections: CachedCollection[]; + directChildren: TaskType[]; initialCollection?: string; item?: TaskType; - onSave: (item: TaskType, collectionUid: string, originalItem?: TaskType) => Promise; - onDelete: (item: TaskType, collectionUid: string) => void; + onSave: (changes: PimChanges[], collectionUid: string) => Promise; + onDelete: (item: TaskType, collectionUid: string, redirect?: boolean, recursive?: boolean) => Promise; onCancel: () => void; history: History; } @@ -61,6 +72,12 @@ export default class TaskEdit extends React.PureComponent { title: string; status: TaskStatusType; priority: TaskPriorityType; + /** + * List of newly created subtasks go here. This list does NOT include tasks that are already + * online, only the ones that are currently queued for creation. + */ + subtasks: string[]; + tempSubtask: string; includeTime: boolean; start?: Date; due?: Date; @@ -70,6 +87,24 @@ export default class TaskEdit extends React.PureComponent { description: string; tags: string[]; collectionUid: string; + /** + * If `deleteTarget` is not defined, this indicates that when the confirmation button + * in the delete dialog is pressed, the current task is deleted. + * When this value is set to a given `TaskType`, the specified task will be deleted. + * This is used when deleting subtask. + */ + deleteTarget?: TaskType; + /** + * If the user's currently focusing on the subtask form, this will become true, and false if not. + * This is used so that when user presses enter, the page can determine whether this enter should + * be used for submitting form, or for adding a new subtask. + */ + creatingSubtasks: boolean; + /** + * Used exclusively for the delete dialog box, if this is checked, this task and all of its + * children are deleted in a recursive manner. + */ + recursiveDelete: boolean; showSelectorDialog: boolean; parentEntry: string | null; @@ -85,11 +120,15 @@ export default class TaskEdit extends React.PureComponent { title: "", status: TaskStatusType.NeedsAction, priority: TaskPriorityType.Undefined, + subtasks: [], + tempSubtask: "", includeTime: false, location: "", description: "", tags: [], timezone: null, + creatingSubtasks: false, + recursiveDelete: false, showSelectorDialog: false, collectionUid: "", @@ -141,6 +180,8 @@ export default class TaskEdit extends React.PureComponent { this.handleRRuleChange = this.handleRRuleChange.bind(this); this.onDeleteRequest = this.onDeleteRequest.bind(this); this.handleCloseToast = this.handleCloseToast.bind(this); + this.onSubtaskAdd = this.onSubtaskAdd.bind(this); + this.onOk = this.onOk.bind(this); } public handleChange(name: string, value: string | number | string[]) { @@ -150,6 +191,13 @@ export default class TaskEdit extends React.PureComponent { } + public onSubtaskAdd() { + const newTaskList = [...this.state.subtasks, this.state.tempSubtask]; + this.setState({ + subtasks: newTaskList, + tempSubtask: "", + }); + } public filterChildren() { if (!this.props.item) { return this.props.entries; @@ -217,6 +265,12 @@ export default class TaskEdit extends React.PureComponent { public onSubmit(e: React.FormEvent) { e.preventDefault(); + if (this.state.creatingSubtasks) { + if (this.state.tempSubtask !== "") { + this.onSubtaskAdd(); + } + return; + } if (this.state.rrule && !(this.state.start || this.state.due)) { this.setState({ error: "A recurring task must have either Hide Until or Due Date set!" }); @@ -284,12 +338,28 @@ export default class TaskEdit extends React.PureComponent { } task.component.updatePropertyWithValue("last-modified", ICAL.Time.now()); + + const tasks: PimChanges[] = [ + ...this.state.subtasks.map((item) => { + const subtask = new TaskType(null); + subtask.uid = uuid.v4(); + subtask.summary = item; + subtask.relatedTo = task.uid; + return { + new: subtask, + }; + }), + { + new: task, + original: this.props.item, + }, + ]; - this.props.onSave(task, this.state.collectionUid, this.props.item) + this.props.onSave(tasks, this.state.collectionUid) .then(() => { const nextTask = task.finished && task.getNextOccurence(); if (nextTask) { - return this.props.onSave(nextTask, this.state.collectionUid); + return this.props.onSave([{ new: nextTask }], this.state.collectionUid); } else { return Promise.resolve(); } @@ -304,10 +374,25 @@ export default class TaskEdit extends React.PureComponent { public onDeleteRequest() { this.setState({ + deleteTarget: undefined, showDeleteDialog: true, + recursiveDelete: false, }); } + public async onOk() { + const redirect = !this.state.deleteTarget; + await this.props.onDelete( + this.state.deleteTarget ?? this.props.item!, + this.props.initialCollection!, + redirect, + this.state.recursiveDelete + ); + if (!redirect) { + this.setState({ showDeleteDialog: false }); + } + } + public render() { const styles = { form: { @@ -409,6 +494,74 @@ export default class TaskEdit extends React.PureComponent { + + Add a new subtask + this.setState({ creatingSubtasks: true })} + onBlur={() => this.setState({ creatingSubtasks: false })} + endAdornment={ + + + + + + } + label="Add a new subtask" + /> + + + + { + this.props.directChildren.map((task) => { + return ( + + + {task.summary} + + + { + this.setState({ + showDeleteDialog: true, + deleteTarget: task, + recursiveDelete: false, + }); + }}> + + + + + ); + }) + } + { + this.state.subtasks.map((taskName, index) => { + return ( + + + {taskName} + + + { + const copy = [...this.state.subtasks]; + copy.splice(index, 1); + this.setState({ subtasks: copy }); + }}> + + + + + ); + }) + } + + Hide until { title="Delete Confirmation" labelOk="Delete" open={this.state.showDeleteDialog} - onOk={() => this.props.onDelete(this.props.item!, this.props.initialCollection!)} + onOk={this.onOk} onCancel={() => this.setState({ showDeleteDialog: false })} > - Are you sure you would like to delete this task? + + + Are you sure you would like to delete + { + this.state.deleteTarget ? ` "${this.state.deleteTarget.summary}"` : " this task" + }? + + + this.setState({ recursiveDelete: e.target.checked })} + /> + } + label="Delete recursively" + /> + + + state.settings.taskSettings); const { filterBy, sortBy } = settings; @@ -179,9 +180,28 @@ export default function TaskList(props: PropsType) { return true; }); + if (showOrphans) { + /** + * `entries` currently contains top level tasks only. Keys of `subEntriesMap` contains + * ID of all parent tasks, whether they actuall exist or not. + * Therefore, orphans can be found by searching for all keys in `subEntriesMap` in + * `entries`. If the key is not in `entries`, this indicates that tasks with that + * key in `subEntriesMap` are orphaned tasks. + * This calculation is done only when `showOrphans` is enabled, so this should not cause + * too much overhead when this option is not on. + */ + for (const key of subEntriesMap.keys()) { + if (entries.find((entry) => entry.uid === key)) { + continue; + } else { + entries.push(...subEntriesMap.get(key)!); + } + } + } + function taskListItemFromTask(entry: TaskType) { const uid = entry.uid; - + return ( diff --git a/src/Tasks/Toolbar.tsx b/src/Tasks/Toolbar.tsx index 2fd877b1..ffb11999 100644 --- a/src/Tasks/Toolbar.tsx +++ b/src/Tasks/Toolbar.tsx @@ -50,10 +50,12 @@ interface PropsType { setShowHidden: (hidden: boolean) => void; searchTerm: string; setSearchTerm: (term: string) => void; + showOrphans: boolean; + setShowOrphans: (orphans: boolean) => void; } export default function Toolbar(props: PropsType) { - const { showCompleted, setShowCompleted, searchTerm, setSearchTerm, showHidden, setShowHidden } = props; + const { showCompleted, setShowCompleted, searchTerm, setSearchTerm, showHidden, setShowHidden, showOrphans, setShowOrphans } = props; const [sortAnchorEl, setSortAnchorEl] = React.useState(null); const [optionsAnchorEl, setOptionsAnchorEl] = React.useState(null); @@ -156,6 +158,12 @@ export default function Toolbar(props: PropsType) { setShowHidden(checked)} edge="end" /> + + Show missing parent + + setShowOrphans(checked)} edge="end" /> + + diff --git a/src/pim-types.ts b/src/pim-types.ts index fbc16876..751f3b9e 100644 --- a/src/pim-types.ts +++ b/src/pim-types.ts @@ -17,6 +17,15 @@ export interface PimType { lastModified: ICAL.Time | undefined; } +export interface PimChanges { + /** + * If `original` is defined, this indicates a change from the `original` to `new`. + * If not, the item in `new` is, well, new. + */ + original?: PimType; + new: PimType; +} + export function timezoneLoadFromName(timezone: string | null) { if (!timezone) { return null;