From bb49a9a9008f1c26f9a20be39991d93c03c46e2a Mon Sep 17 00:00:00 2001 From: MangoCubes <10383115+MangoCubes@users.noreply.github.com> Date: Sun, 23 Jul 2023 17:24:26 +0900 Subject: [PATCH 1/7] Helper functions: Functions updated to allow multiple edits/deletes --- src/Calendars/Main.tsx | 9 ++++-- src/Contacts/Main.tsx | 9 ++++-- src/Pim/helpers.tsx | 71 ++++++++++++++++++++++-------------------- src/Tasks/Main.tsx | 17 +++++++--- src/Tasks/TaskEdit.tsx | 8 ++--- src/pim-types.ts | 9 ++++++ 6 files changed, 77 insertions(+), 46 deletions(-) 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..631df209 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,13 +57,20 @@ export default function TasksMain() { } async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise { + await onMultipleItemsSave([{ + original: originalItem, + new: item, + }], collectionUid); + } + + async function onMultipleItemsSave(changes: PimChanges[], collectionUid: string): Promise { const collection = collections!.find((x) => x.uid === collectionUid)!; - await itemSave(etebase, collection, items!, item, collectionUid, originalItem); + await itemSave(etebase, collection, items!, collectionUid, changes); } 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.tasks")); } @@ -114,7 +121,7 @@ export default function TasksMain() { > Promise; + onSave: (changes: PimChanges[], collectionUid: string) => Promise; onDelete: (item: TaskType, collectionUid: string) => void; onCancel: () => void; history: History; @@ -240,11 +240,11 @@ export default class TaskEdit extends React.PureComponent { task.component.updatePropertyWithValue("last-modified", ICAL.Time.now()); - this.props.onSave(task, this.state.collectionUid, this.props.item) + this.props.onSave([{ new: task, original: this.props.item }], 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(); } 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; From e3efe130efea3a6189badcea966bee0104cdcdcf Mon Sep 17 00:00:00 2001 From: MangoCubes <10383115+MangoCubes@users.noreply.github.com> Date: Sun, 23 Jul 2023 17:30:16 +0900 Subject: [PATCH 2/7] Tasks edit: Added components for subtask list in the edit screen --- src/Tasks/Main.tsx | 4 ++- src/Tasks/TaskEdit.tsx | 61 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/Tasks/Main.tsx b/src/Tasks/Main.tsx index 631df209..b7cbdcde 100644 --- a/src/Tasks/Main.tsx +++ b/src/Tasks/Main.tsx @@ -79,7 +79,7 @@ export default function TasksMain() { history.goBack(); } - const flatEntries = []; + const flatEntries: TaskType[] = []; for (const col of entries.values()) { for (const item of col.values()) { flatEntries.push(item); @@ -120,6 +120,7 @@ export default function TasksMain() { exact > t.relatedTo === item.uid)} key={itemUid} initialCollection={item.collectionUid} item={item} diff --git a/src/Tasks/TaskEdit.tsx b/src/Tasks/TaskEdit.tsx index 9f0a9c40..a7929693 100644 --- a/src/Tasks/TaskEdit.tsx +++ b/src/Tasks/TaskEdit.tsx @@ -20,6 +20,7 @@ import RadioGroup from "@material-ui/core/RadioGroup"; 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"; @@ -42,9 +43,11 @@ import { History } from "history"; import ColoredRadio from "../widgets/ColoredRadio"; import RRule, { RRuleOptions } from "../widgets/RRule"; import { CachedCollection } from "../Pim/helpers"; +import { IconButton, InputAdornment, List, ListItem, ListItemText, OutlinedInput } from "@material-ui/core"; interface PropsType { collections: CachedCollection[]; + directChildren: TaskType[]; initialCollection?: string; item?: TaskType; onSave: (changes: PimChanges[], collectionUid: string) => Promise; @@ -59,6 +62,8 @@ export default class TaskEdit extends React.PureComponent { title: string; status: TaskStatusType; priority: TaskPriorityType; + subtasks: string[]; + tempSubtask: string; includeTime: boolean; start?: Date; due?: Date; @@ -80,6 +85,8 @@ export default class TaskEdit extends React.PureComponent { title: "", status: TaskStatusType.NeedsAction, priority: TaskPriorityType.Undefined, + subtasks: [], + tempSubtask: "", includeTime: false, location: "", description: "", @@ -135,6 +142,7 @@ 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); } public handleChange(name: string, value: string | number | string[]) { @@ -144,6 +152,14 @@ export default class TaskEdit extends React.PureComponent { } + public onSubtaskAdd() { + const newTaskList = [...this.state.subtasks, this.state.tempSubtask]; + this.setState({ + subtasks: newTaskList, + tempSubtask: "", + }); + } + public handleInputChange(event: React.ChangeEvent) { const name = event.target.name; const value = event.target.value; @@ -353,6 +369,51 @@ export default class TaskEdit extends React.PureComponent { + + { + this.props.directChildren.map((task) => { + return ( + + + {task.summary} + + + ); + }) + } + { + this.state.subtasks.map((taskName, index) => { + return ( + + + {taskName} + + + ); + }) + } + + + + Add a new subtask + + + + + + } + label="Add a new subtask" + /> + + Hide until Date: Sun, 23 Jul 2023 17:32:35 +0900 Subject: [PATCH 3/7] Tasks edit: Implemented core functionality of the subtask creation feature --- src/Tasks/Main.tsx | 6 ++- src/Tasks/TaskEdit.tsx | 108 +++++++++++++++++++++++++++++++---------- 2 files changed, 87 insertions(+), 27 deletions(-) diff --git a/src/Tasks/Main.tsx b/src/Tasks/Main.tsx index b7cbdcde..2d897a17 100644 --- a/src/Tasks/Main.tsx +++ b/src/Tasks/Main.tsx @@ -68,11 +68,13 @@ export default function TasksMain() { await itemSave(etebase, collection, items!, collectionUid, changes); } - async function onItemDelete(item: PimType, collectionUid: string) { + async function onItemDelete(item: PimType, collectionUid: string, redirect = true) { const collection = collections!.find((x) => x.uid === collectionUid)!; await itemDelete(etebase, collection, items!, [item], collectionUid); - history.push(routeResolver.getRoute("pim.tasks")); + if (redirect) { + history.push(routeResolver.getRoute("pim.tasks")); + } } function onCancel() { diff --git a/src/Tasks/TaskEdit.tsx b/src/Tasks/TaskEdit.tsx index a7929693..21d5e1d9 100644 --- a/src/Tasks/TaskEdit.tsx +++ b/src/Tasks/TaskEdit.tsx @@ -43,7 +43,7 @@ import { History } from "history"; import ColoredRadio from "../widgets/ColoredRadio"; import RRule, { RRuleOptions } from "../widgets/RRule"; import { CachedCollection } from "../Pim/helpers"; -import { IconButton, InputAdornment, List, ListItem, ListItemText, OutlinedInput } from "@material-ui/core"; +import { IconButton, InputAdornment, List, ListItem, ListItemSecondaryAction, ListItemText, OutlinedInput } from "@material-ui/core"; interface PropsType { collections: CachedCollection[]; @@ -51,7 +51,7 @@ interface PropsType { initialCollection?: string; item?: TaskType; onSave: (changes: PimChanges[], collectionUid: string) => Promise; - onDelete: (item: TaskType, collectionUid: string) => void; + onDelete: (item: TaskType, collectionUid: string, redirect?: boolean) => Promise; onCancel: () => void; history: History; } @@ -73,6 +73,13 @@ 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; error?: string; showDeleteDialog: boolean; @@ -143,6 +150,7 @@ export default class TaskEdit extends React.PureComponent { 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[]) { @@ -255,8 +263,24 @@ 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([{ new: task, original: this.props.item }], this.state.collectionUid) + this.props.onSave(tasks, this.state.collectionUid) .then(() => { const nextTask = task.finished && task.getNextOccurence(); if (nextTask) { @@ -279,6 +303,18 @@ export default class TaskEdit extends React.PureComponent { }); } + public async onOk() { + const redirect = !this.state.deleteTarget; + await this.props.onDelete( + this.state.deleteTarget ?? this.props.item!, + this.props.initialCollection!, + redirect + ); + if (!redirect) { + this.setState({ showDeleteDialog: false }); + } + } + public render() { const styles = { form: { @@ -369,6 +405,26 @@ export default class TaskEdit extends React.PureComponent { + + Add a new subtask + + + + + + } + label="Add a new subtask" + /> + + { this.props.directChildren.map((task) => { @@ -377,6 +433,16 @@ export default class TaskEdit extends React.PureComponent { {task.summary} + + { + this.setState({ + showDeleteDialog: true, + deleteTarget: task, + }); + }}> + + + ); }) @@ -388,32 +454,21 @@ export default class TaskEdit extends React.PureComponent { {taskName} + + { + const copy = [...this.state.subtasks]; + copy.splice(index, 1); + this.setState({ subtasks: copy }); + }}> + + + ); }) } - - Add a new subtask - - - - - - } - label="Add a new subtask" - /> - - 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" + }? ); From a102534bd1e17a0e8d476b1f27279b01571b4be9 Mon Sep 17 00:00:00 2001 From: MangoCubes <10383115+MangoCubes@users.noreply.github.com> Date: Sun, 23 Jul 2023 17:36:07 +0900 Subject: [PATCH 4/7] Tasks edit: Added supporting features (Press enter to create new subtask, no empty task, recursive task delete) --- src/Tasks/Main.tsx | 28 +++++++++++++++++--------- src/Tasks/TaskEdit.tsx | 45 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/Tasks/Main.tsx b/src/Tasks/Main.tsx index 2d897a17..cde14821 100644 --- a/src/Tasks/Main.tsx +++ b/src/Tasks/Main.tsx @@ -68,15 +68,6 @@ export default function TasksMain() { await itemSave(etebase, collection, items!, collectionUid, changes); } - async function onItemDelete(item: PimType, collectionUid: string, redirect = true) { - const collection = collections!.find((x) => x.uid === collectionUid)!; - await itemDelete(etebase, collection, items!, [item], collectionUid); - - if (redirect) { - history.push(routeResolver.getRoute("pim.tasks")); - } - } - function onCancel() { history.goBack(); } @@ -88,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), diff --git a/src/Tasks/TaskEdit.tsx b/src/Tasks/TaskEdit.tsx index 21d5e1d9..fc865162 100644 --- a/src/Tasks/TaskEdit.tsx +++ b/src/Tasks/TaskEdit.tsx @@ -43,7 +43,7 @@ import { History } from "history"; import ColoredRadio from "../widgets/ColoredRadio"; import RRule, { RRuleOptions } from "../widgets/RRule"; import { CachedCollection } from "../Pim/helpers"; -import { IconButton, InputAdornment, List, ListItem, ListItemSecondaryAction, ListItemText, OutlinedInput } from "@material-ui/core"; +import { Checkbox, IconButton, InputAdornment, List, ListItem, ListItemSecondaryAction, ListItemText, OutlinedInput } from "@material-ui/core"; interface PropsType { collections: CachedCollection[]; @@ -51,7 +51,7 @@ interface PropsType { initialCollection?: string; item?: TaskType; onSave: (changes: PimChanges[], collectionUid: string) => Promise; - onDelete: (item: TaskType, collectionUid: string, redirect?: boolean) => Promise; + onDelete: (item: TaskType, collectionUid: string, redirect?: boolean, recursive?: boolean) => Promise; onCancel: () => void; history: History; } @@ -62,6 +62,10 @@ 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; @@ -80,6 +84,17 @@ export default class TaskEdit extends React.PureComponent { * 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; error?: string; showDeleteDialog: boolean; @@ -99,6 +114,8 @@ export default class TaskEdit extends React.PureComponent { description: "", tags: [], timezone: null, + creatingSubtasks: false, + recursiveDelete: false, collectionUid: "", showDeleteDialog: false, @@ -197,6 +214,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!" }); @@ -299,7 +322,9 @@ export default class TaskEdit extends React.PureComponent { public onDeleteRequest() { this.setState({ + deleteTarget: undefined, showDeleteDialog: true, + recursiveDelete: false, }); } @@ -308,7 +333,8 @@ export default class TaskEdit extends React.PureComponent { await this.props.onDelete( this.state.deleteTarget ?? this.props.item!, this.props.initialCollection!, - redirect + redirect, + this.state.recursiveDelete ); if (!redirect) { this.setState({ showDeleteDialog: false }); @@ -411,11 +437,14 @@ export default class TaskEdit extends React.PureComponent { name="tempSubtask" value={this.state.tempSubtask} onChange={this.handleInputChange} + onFocus={() => this.setState({ creatingSubtasks: true })} + onBlur={() => this.setState({ creatingSubtasks: false })} endAdornment={ @@ -438,6 +467,7 @@ export default class TaskEdit extends React.PureComponent { this.setState({ showDeleteDialog: true, deleteTarget: task, + recursiveDelete: false, }); }}> @@ -610,6 +640,15 @@ export default class TaskEdit extends React.PureComponent { { this.state.deleteTarget ? ` "${this.state.deleteTarget.summary}"` : " this task" }? + this.setState({ recursiveDelete: e.target.checked })} + /> + } + label="Delete recursively" + /> ); From 2a63b049edf7bd5fdededee041fea2b19948e942 Mon Sep 17 00:00:00 2001 From: MangoCubes <10383115+MangoCubes@users.noreply.github.com> Date: Sun, 23 Jul 2023 17:36:49 +0900 Subject: [PATCH 5/7] Tasks: Added a way to view tasks with missing parent --- src/Tasks/TaskList.tsx | 24 +++++++++++++++++++++++- src/Tasks/Toolbar.tsx | 10 +++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) 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/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" /> + + From 2d4de667e91e57af2865dad8613b93af06831dde Mon Sep 17 00:00:00 2001 From: MangoCubes <10383115+MangoCubes@users.noreply.github.com> Date: Sun, 23 Jul 2023 17:57:24 +0900 Subject: [PATCH 6/7] Tasks edit: Merged parent selector feature into subtask list feature --- src/Tasks/Main.tsx | 2 + src/Tasks/TaskEdit.tsx | 62 +++++++++++++++++++++++++++ src/Tasks/TaskSelector.tsx | 69 ++++++++++++++++++++++++++++++ src/Tasks/TaskSelectorListItem.tsx | 34 +++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 src/Tasks/TaskSelector.tsx create mode 100644 src/Tasks/TaskSelectorListItem.tsx diff --git a/src/Tasks/Main.tsx b/src/Tasks/Main.tsx index cde14821..052bff55 100644 --- a/src/Tasks/Main.tsx +++ b/src/Tasks/Main.tsx @@ -133,6 +133,7 @@ export default function TasksMain() { > t.relatedTo === item.uid)} + entries={flatEntries} key={itemUid} initialCollection={item.collectionUid} item={item} diff --git a/src/Tasks/TaskEdit.tsx b/src/Tasks/TaskEdit.tsx index fc865162..3651caf2 100644 --- a/src/Tasks/TaskEdit.tsx +++ b/src/Tasks/TaskEdit.tsx @@ -44,8 +44,10 @@ import ColoredRadio from "../widgets/ColoredRadio"; import RRule, { RRuleOptions } from "../widgets/RRule"; import { CachedCollection } from "../Pim/helpers"; import { Checkbox, IconButton, InputAdornment, List, ListItem, ListItemSecondaryAction, ListItemText, OutlinedInput } from "@material-ui/core"; +import TaskSelector from "./TaskSelector"; interface PropsType { + entries: TaskType[]; collections: CachedCollection[]; directChildren: TaskType[]; initialCollection?: string; @@ -95,6 +97,8 @@ export default class TaskEdit extends React.PureComponent { * children are deleted in a recursive manner. */ recursiveDelete: boolean; + showSelectorDialog: boolean; + parentEntry: string | null; error?: string; showDeleteDialog: boolean; @@ -103,6 +107,7 @@ export default class TaskEdit extends React.PureComponent { constructor(props: PropsType) { super(props); this.state = { + parentEntry: props.item?.relatedTo ?? "", uid: "", title: "", status: TaskStatusType.NeedsAction, @@ -116,6 +121,7 @@ export default class TaskEdit extends React.PureComponent { timezone: null, creatingSubtasks: false, recursiveDelete: false, + showSelectorDialog: false, collectionUid: "", showDeleteDialog: false, @@ -184,6 +190,43 @@ export default class TaskEdit extends React.PureComponent { 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; @@ -261,6 +304,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; } @@ -400,6 +444,17 @@ export default class TaskEdit extends React.PureComponent { + + e.uid === this.state.parentEntry)?.title ?? "None"} + /> + Status @@ -650,6 +705,13 @@ export default class TaskEdit extends React.PureComponent { label="Delete recursively" /> + this.setState({ showSelectorDialog: false, parentEntry: entry })} + onCancel={() => this.setState({ showSelectorDialog: false })} + /> ); } 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 From d1d380d9d58f355214339d647188d53fbe8ecb56 Mon Sep 17 00:00:00 2001 From: MangoCubes <10383115+MangoCubes@users.noreply.github.com> Date: Sun, 23 Jul 2023 18:15:52 +0900 Subject: [PATCH 7/7] Tasks edit: Fixing layout for delete confirmation dialog --- src/Tasks/TaskEdit.tsx | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/Tasks/TaskEdit.tsx b/src/Tasks/TaskEdit.tsx index 3651caf2..908f4ea6 100644 --- a/src/Tasks/TaskEdit.tsx +++ b/src/Tasks/TaskEdit.tsx @@ -43,7 +43,7 @@ import { History } from "history"; import ColoredRadio from "../widgets/ColoredRadio"; import RRule, { RRuleOptions } from "../widgets/RRule"; import { CachedCollection } from "../Pim/helpers"; -import { Checkbox, IconButton, InputAdornment, List, ListItem, ListItemSecondaryAction, ListItemText, OutlinedInput } from "@material-ui/core"; +import { Checkbox, Grid, IconButton, InputAdornment, List, ListItem, ListItemSecondaryAction, ListItemText, OutlinedInput } from "@material-ui/core"; import TaskSelector from "./TaskSelector"; interface PropsType { @@ -691,19 +691,31 @@ export default class TaskEdit extends React.PureComponent { onOk={this.onOk} onCancel={() => this.setState({ showDeleteDialog: false })} > - Are you sure you would like to delete - { - this.state.deleteTarget ? ` "${this.state.deleteTarget.summary}"` : " this task" - }? - this.setState({ recursiveDelete: e.target.checked })} + + + 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" /> - } - label="Delete recursively" - /> + + +