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 e8d973eb..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,28 +57,47 @@ 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() { history.goBack(); } - const flatEntries = []; + const flatEntries: TaskType[] = []; for (const col of entries.values()) { for (const item of col.values()) { flatEntries.push(item); } } + 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,8 +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 4013b1ab..81dff3f9 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,20 +45,23 @@ 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"; import ColoredRadio from "../widgets/ColoredRadio"; import RRule, { RRuleOptions } from "../widgets/RRule"; import { CachedCollection } from "../Pim/helpers"; +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; } @@ -59,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; @@ -68,6 +87,26 @@ 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; error?: string; showDeleteDialog: boolean; @@ -76,15 +115,21 @@ export default class TaskEdit extends React.PureComponent { constructor(props: PropsType) { super(props); this.state = { + parentEntry: props.item?.relatedTo ?? "", uid: "", title: "", status: TaskStatusType.NeedsAction, priority: TaskPriorityType.Undefined, + subtasks: [], + tempSubtask: "", includeTime: false, location: "", description: "", tags: [], timezone: null, + creatingSubtasks: false, + recursiveDelete: false, + showSelectorDialog: false, collectionUid: "", showDeleteDialog: false, @@ -135,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[]) { @@ -144,6 +191,51 @@ 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; + } + const idsToRemove: string[] = [this.props.item.uid]; + const parentMap: {[itemId: string]: TaskType[]} = { "": [] }; + for (const e of this.props.entries) { + if (e.uid === this.props.item.uid) { + continue; + } + if (!e.relatedTo) { + parentMap[""].push(e); + } else { + if (parentMap[e.relatedTo]) { + parentMap[e.relatedTo].push(e); + } else { + parentMap[e.relatedTo] = [e]; + } + } + } + while (idsToRemove.length > 0) { + const current = idsToRemove.shift()!; + const children = parentMap[current]; + if (!children) { + continue; + } + for (const c of children) { + idsToRemove.push(c.uid); + } + delete parentMap[current]; + } + const ret: TaskType[] = []; + for (const k in parentMap) { + ret.push(...parentMap[k]); + } + return ret; + } + public handleInputChange(event: React.ChangeEvent) { const name = event.target.name; const value = event.target.value; @@ -173,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!" }); @@ -214,6 +312,7 @@ export default class TaskEdit extends React.PureComponent { task.status = this.state.status; task.priority = this.state.priority; task.tags = this.state.tags; + task.relatedTo = this.state.parentEntry ?? undefined; if (startDate) { task.startDate = startDate; } @@ -239,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(); } @@ -259,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: { @@ -322,6 +452,17 @@ export default class TaskEdit extends React.PureComponent { + + e.uid === this.state.parentEntry)?.title ?? "None"} + /> + Status @@ -353,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" + /> + + + + this.setState({ showSelectorDialog: false, parentEntry: entry })} + onCancel={() => this.setState({ showSelectorDialog: false })} + /> ); } diff --git a/src/Tasks/TaskList.tsx b/src/Tasks/TaskList.tsx index b2fa1e3b..e97745a9 100644 --- a/src/Tasks/TaskList.tsx +++ b/src/Tasks/TaskList.tsx @@ -108,6 +108,7 @@ interface PropsType { export default function TaskList(props: PropsType) { const [showCompleted, setShowCompleted] = React.useState(false); const [showHidden, setShowHidden] = React.useState(false); + const [showOrphans, setShowOrphans] = React.useState(false); const [searchTerm, setSearchTerm] = React.useState(""); const settings = useSelector((state: StoreState) => 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/TaskSelector.tsx b/src/Tasks/TaskSelector.tsx new file mode 100644 index 00000000..a378014c --- /dev/null +++ b/src/Tasks/TaskSelector.tsx @@ -0,0 +1,69 @@ +import { TaskType } from "../pim-types"; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, FormGroup, List, Switch } from "@material-ui/core"; +import React from "react"; +import TaskSelectorListItem from "./TaskSelectorListItem"; + +interface PropsType { + entries: TaskType[]; + orig: string | null; + open: boolean; + onConfirm: (entry: string | null) => void; + onCancel: () => void; +} + +export default function TaskSelector(props: PropsType) { + + const [showHidden, setShowHidden] = React.useState(false); + const [showCompleted, setShowCompleted] = React.useState(false); + + const itemList = props.entries + .filter((e) => !e.relatedTo && (showHidden || !e.hidden) && (showCompleted || !e.finished)) + .map((e) => + + ); + + return ( + + + Select parent task + + + + setShowCompleted(e.target.checked)} + /> + } + label="Show completed" + /> + setShowHidden(e.target.checked)} + /> + } + label="Show hidden" + /> + + + {itemList} + + + + + + + ); +} \ No newline at end of file diff --git a/src/Tasks/TaskSelectorListItem.tsx b/src/Tasks/TaskSelectorListItem.tsx new file mode 100644 index 00000000..948fca48 --- /dev/null +++ b/src/Tasks/TaskSelectorListItem.tsx @@ -0,0 +1,34 @@ +import { TaskType } from "../pim-types"; +import { ListItem } from "../widgets/List"; +import React from "react"; + +interface PropsType { + entries: TaskType[]; + showHidden: boolean; + showCompleted: boolean; + onClick: (uid: string) => void; + thisEntry: TaskType; +} + +export default function TaskSelectorListItem(props: PropsType) { + const tasks = props.entries + .filter((e) => e.relatedTo === props.thisEntry.uid && (props.showHidden || !e.hidden) && (props.showCompleted || !e.finished)); + + return ( + props.onClick(props.thisEntry.uid)} + nestedItems={tasks.map((e) => + + )} + /> + ); +} \ No newline at end of file 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;