From bb54d5742089fbe888d7291563d37233f3e982c9 Mon Sep 17 00:00:00 2001 From: Andrew P Maney Date: Fri, 17 Jan 2020 12:09:11 -0800 Subject: [PATCH 1/4] Adds tags, cleans up edit form, better datetimepicker --- src/App.tsx | 2 +- src/Pim/PimMain.tsx | 8 +++- src/Pim/index.tsx | 18 ++++---- src/components/TaskEdit.tsx | 73 ++++++++------------------------- src/components/TaskList.tsx | 82 ++++++++++++++++++++++++++++--------- src/pim-types.ts | 8 ++++ yarn.lock | 5 +++ 7 files changed, 112 insertions(+), 84 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5e898002..4b9b6daf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,7 +41,7 @@ const muiTheme = createMuiTheme({ light: lightBlue.A200, main: lightBlue.A400, dark: lightBlue.A700, - contrastText: 'white', + contrastText: '#fff', }, }, }); diff --git a/src/Pim/PimMain.tsx b/src/Pim/PimMain.tsx index fbf406ea..6b38c2c1 100644 --- a/src/Pim/PimMain.tsx +++ b/src/Pim/PimMain.tsx @@ -7,6 +7,8 @@ import { Theme, withTheme } from '@material-ui/core/styles'; import * as ICAL from 'ical.js'; +import * as EteSync from 'etesync'; + import { Location, History } from 'history'; import Container from '../widgets/Container'; @@ -15,7 +17,7 @@ import SearchableAddressBook from '../components/SearchableAddressBook'; import Calendar from '../components/Calendar'; import TaskList from '../components/TaskList'; -import { EventType, ContactType, TaskType } from '../pim-types'; +import { EventType, ContactType, TaskType, PimType } from '../pim-types'; import { routeResolver } from '../App'; @@ -34,6 +36,8 @@ interface PropsType { location?: Location; history?: History; theme: Theme; + collectionsTaskList: EteSync.CollectionInfo[]; + onItemSave: (item: PimType, journalUid: string, originalContact?: PimType) => void; } class PimMain extends React.PureComponent { @@ -143,7 +147,9 @@ class PimMain extends React.PureComponent { {tab === 2 && } diff --git a/src/Pim/index.tsx b/src/Pim/index.tsx index 4167e5c3..678537c3 100644 --- a/src/Pim/index.tsx +++ b/src/Pim/index.tsx @@ -44,14 +44,14 @@ function objValues(obj: any) { } const itemsSelector = createSelector( - (props: {syncInfo: SyncInfo}) => props.syncInfo, + (props: { syncInfo: SyncInfo }) => props.syncInfo, (syncInfo) => { const collectionsAddressBook: EteSync.CollectionInfo[] = []; const collectionsCalendar: EteSync.CollectionInfo[] = []; const collectionsTaskList: EteSync.CollectionInfo[] = []; - let addressBookItems: {[key: string]: ContactType} = {}; - let calendarItems: {[key: string]: EventType} = {}; - let taskListItems: {[key: string]: TaskType} = {}; + let addressBookItems: { [key: string]: ContactType } = {}; + let calendarItems: { [key: string]: EventType } = {}; + let taskListItems: { [key: string]: TaskType } = {}; syncInfo.forEach( (syncJournal) => { const syncEntries = syncJournal.entries; @@ -106,7 +106,7 @@ type CollectionRoutesPropsType = RouteComponentProps<{}> & { collections: EteSync.CollectionInfo[]; componentEdit: any; componentView: any; - items: {[key: string]: PimType}; + items: { [key: string]: PimType }; onItemSave: (item: PimType, journalUid: string, originalContact?: PimType) => void; onItemDelete: (item: PimType, journalUid: string) => void; onItemCancel: () => void; @@ -191,7 +191,7 @@ const CollectionRoutes = withStyles(styles)(withRouter( } > - Change History + Change History - -
- Not all types are supported at the moment. If you are editing a contact, - the unsupported types will be copied as is. -
a.title.localeCompare(b.title), + dueDate: (a: TaskType, b: TaskType) => { + if (!a.dueDate) { return 1 } + if (!b.dueDate) { return -1 } + return a.dueDate.toJSDate() < b.dueDate.toJSDate() ? -1 : 1; + }, + priority: (a: TaskType, b: TaskType) => { + if (a.priority === TaskPriorityType.None) { return 1 } + if (b.priority === TaskPriorityType.None) { return -1 } + return a.priority - b.priority; + }, + lastModified: (a: TaskType, b: TaskType) => { + if (!a.lastModified) { return 1 } + if (!b.lastModified) { return -1 } + return a.lastModified.toJSDate() > b.lastModified.toJSDate() ? -1 : 1; + }, +}; + +const tagFilters = Object.assign( + {}, + ...TaskTags.map((tag) => ({ + [tag]: (x: TaskType) => x.categories.includes(tag), + })) +); + +// FIXME: this breaks if the user has tags by the name "all" or "today" +const filters = { + all: () => true, + today: (x: TaskType) => x.dueDate ? moment(x.dueDate.toJSDate()).isSame(moment(), 'day') : false, + ...tagFilters, +}; + +let fuseMemo: { + tasks: TaskType[]; + fuse: Fuse; +} = { tasks: [], fuse: new Fuse([], {}) }; + +interface PropsType { + entries: TaskType[]; + onItemClick: (task: TaskType) => void; + theme: Theme; +} + +export default React.memo(withTheme(function TaskList(props: PropsType) { + const dispatch = useDispatch(); + const settings = useSelector((state: StoreState) => state.settings.tasks); + const { sortOrder, showCompleted, filterBy, searchTerm } = settings; + + if (fuseMemo.tasks.length === 0 || (fuseMemo.tasks !== props.entries)) { + fuseMemo = { + tasks: props.entries, + fuse: new Fuse(props.entries, { + shouldSort: true, + threshold: 0.6, + maxPatternLength: 32, + minMatchCharLength: 2, + keys: [ + 'title', + ], + }), + }; + } + + const handleSearch = (term: string) => { + if (term) { + dispatch(setSettings({ tasks: { ...settings, filterBy: 'search', searchTerm: term } })); + } else { + // set back to all when clearing the field + dispatch(setSettings({ tasks: { ...settings, filterBy: 'all', searchTerm: '' } })); + } + }; + + let tasks = []; + if (filterBy === 'search') { + tasks = fuseMemo.fuse.search(searchTerm); + } else { + tasks = props.entries.filter(filters[filterBy]); + } + tasks = tasks.filter((x) => (showCompleted || !x.finished)).sort(comparators[sortOrder]); + + const itemList = tasks.map((task: TaskType) => { + const uid = task.uid; + + return ; + }); + + // counts tags and creates an object with shape { tag: amount } + const tags = TaskTags.reduce((obj, tag) => ({ ...obj, [tag]: 0 }), {}); + props.entries.filter((x) => (showCompleted || !x.finished)).forEach((entry) => entry.categories.forEach((tag) => { + if (Object.prototype.hasOwnProperty.call(tags, tag)) { tags[tag]++ } + })); + + return ( + + + + + + + + + + {itemList} + + + + + + + ); +})); diff --git a/src/components/Tasks/TaskListItem.tsx b/src/components/Tasks/TaskListItem.tsx new file mode 100644 index 00000000..a02527ae --- /dev/null +++ b/src/components/Tasks/TaskListItem.tsx @@ -0,0 +1,67 @@ +import React, { useContext } from 'react'; + +import moment from 'moment'; + +import { Chip } from '@material-ui/core'; +import Checkbox from '@material-ui/core/Checkbox'; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; + +import { TaskType, TaskStatusType, TaskPriorityType } from '../../pim-types'; +import { ListItem } from '../../widgets/List'; +import { PimContext } from '../../Pim'; + +const TagList = React.memo((props: { tags: string[] }) => ( +
    + {props.tags.map((tag, i) => tag && )} +
)); + +// FIXME: HACK the default colors just happen to work, probably want hand-picked colors +const checkboxColor: { [key: number]: 'inherit' | 'secondary' | 'primary' | 'error' } = { + [TaskPriorityType.None]: 'inherit', + [TaskPriorityType.Low]: 'secondary', + [TaskPriorityType.Med]: 'primary', + [TaskPriorityType.High]: 'error', +}; + +interface PropsType { + task: TaskType; + onClick: (task: TaskType) => void; +} + +const TaskListItem = React.memo( + (props: PropsType) => { + const { task, onClick } = props; + const title = task.title; + const { onItemSave: save } = useContext(PimContext); + + const toggleComplete = (_e: React.ChangeEvent, checked: boolean) => { + const clonedTask = task.clone(); + clonedTask.status = checked ? TaskStatusType.Completed : TaskStatusType.NeedsAction; + save(clonedTask, (task as any).journalUid, task, false); + }; + + return onClick(task)} + leftIcon={ + e.stopPropagation()} + onChange={toggleComplete} + checked={task.status === TaskStatusType.Completed} + icon={} + /> + } + rightIcon={} + secondaryText={task.dueDate && `Due ${moment().to(task.dueDate.toJSDate())}`} + />; + } +); + +export default TaskListItem; diff --git a/src/components/Tasks/Toolbar.tsx b/src/components/Tasks/Toolbar.tsx new file mode 100644 index 00000000..d34909ce --- /dev/null +++ b/src/components/Tasks/Toolbar.tsx @@ -0,0 +1,112 @@ +import React from 'react'; + +import Grid from '@material-ui/core/Grid'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; +import InputLabel from '@material-ui/core/InputLabel'; +import FormControl from '@material-ui/core/FormControl'; +import TextField from '@material-ui/core/TextField'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import SearchIcon from '@material-ui/icons/Search'; +import Switch from '@material-ui/core/Switch'; +import Autocomplete from '@material-ui/lab/Autocomplete'; + +import { useSelector, useDispatch } from 'react-redux'; +import { StoreState } from '../../store'; +import { setSettings } from '../../store/actions'; + +import { TaskType } from '../../pim-types'; + +interface PropsType { + tasks: TaskType[]; + onSearch: (term: string) => void; +} + +export default React.memo((props: PropsType) => { + const { tasks, onSearch: search } = props; + + const dispatch = useDispatch(); + const settings = useSelector((state: StoreState) => state.settings.tasks); + const { sortOrder, showCompleted } = settings; + + const handleSelectChange = (e: React.ChangeEvent<{ value: string }>) => { + dispatch(setSettings({ tasks: { ...settings, sortOrder: e.target.value } })); + }; + + const handleAutocompleteChange = (_e: React.ChangeEvent<{}>, value: TaskType | string | null) => { + switch (typeof value) { + case 'string': + search(value); + break; + case 'object': + search(value ? value.title : ''); + break; + default: + search(''); + } + }; + + const handleSwitchChange = () => { + dispatch(setSettings({ tasks: { ...settings, showCompleted: !showCompleted } })); + }; + + return ( + + + + Sort + + + + + + task.title)} + onChange={handleAutocompleteChange} + renderInput={(params) => { + return ( + + + + ), + }} + /> + ); + }} + /> + + + + + } + label="Show Completed" + /> + + + ); +}); diff --git a/src/pim-types.ts b/src/pim-types.ts index 2684b51b..7adc1a6d 100644 --- a/src/pim-types.ts +++ b/src/pim-types.ts @@ -80,6 +80,10 @@ export class EventType extends ICAL.Event implements PimType { return this.summary; } + set title(_title: string) { + this.summary = _title; + } + get start() { return this.startDate.toJSDate(); } @@ -120,6 +124,15 @@ export enum TaskStatusType { Cancelled = 'CANCELLED', } +export enum TaskPriorityType { + None = 0, + High = 1, + Med = 2, + Low = 3, +} + +export const TaskTags = ['work', 'home']; + export class TaskType extends EventType { public static fromVCalendar(comp: ICAL.Component) { const task = new TaskType(comp.getFirstSubcomponent('vtodo')); @@ -137,6 +150,7 @@ export class TaskType extends EventType { constructor(comp?: ICAL.Component | null) { super(comp ? comp : new ICAL.Component('vtodo')); this.component.addProperty(new ICAL.Property('categories')); + this.component.addProperty(new ICAL.Property('priority')); } get finished() { @@ -193,6 +207,22 @@ export class TaskType extends EventType { return this.component.getFirstProperty('categories').getValues(); } + set priority(priority: TaskPriorityType) { + this.component.updatePropertyWithValue('priority', priority); + } + + get priority() { + return this.component.getFirstPropertyValue('priority'); + } + + get lastModified() { + return this.component.getFirstPropertyValue('last-modified'); + } + + set lastModified(time: ICAL.Time) { + this.component.updatePropertyWithValue('last-modified', time); + } + public clone() { const ret = new TaskType(new ICAL.Component(this.component.toJSON())); ret.color = this.color; diff --git a/src/store/actions.ts b/src/store/actions.ts index 1d05ffe5..4a52028e 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -223,7 +223,7 @@ export const clearErros = createAction( // FIXME: Move the rest to their own file export const setSettings = createAction( 'SET_SETTINGS', - (settings: SettingsType) => { + (settings: Partial) => { return { ...settings }; } ); diff --git a/src/store/reducers.ts b/src/store/reducers.ts index 9e8cbd87..b2f512cf 100644 --- a/src/store/reducers.ts +++ b/src/store/reducers.ts @@ -291,15 +291,32 @@ export const errorsReducer = handleActions( // FIXME Move all the below (potentially the fetchCount ones too) to their own file +export interface TaskSettings { + showCompleted: boolean; + sortOrder: string; + filterBy: string; + searchTerm: string; +} + export interface SettingsType { locale: string; + tasks: TaskSettings; } export const settingsReducer = handleActions( { - [actions.setSettings.toString()]: (_state: {key: string | null}, action: any) => ( - { ...action.payload } + [actions.setSettings.toString()]: (state: SettingsType, action: any) => ( + { ...state, ...action.payload } ), }, - { locale: 'en-gb' } + { + locale: 'en-gb', + + tasks: { + showCompleted: false, + sortOrder: 'dueDate', + filterBy: 'all', + searchTerm: '', + }, + } ); diff --git a/src/types/ical.js.d.ts b/src/types/ical.js.d.ts index 20be83f1..181d9d96 100644 --- a/src/types/ical.js.d.ts +++ b/src/types/ical.js.d.ts @@ -57,7 +57,6 @@ declare module 'ical.js' { public getFirstValue(): T; public getValues(): T[]; - public setValues(values: any[]): void; public setParameter(name: string, value: string | string[]): void; public setValue(value: string | object): void; diff --git a/tsconfig.json b/tsconfig.json index e4bdb455..04b47852 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "outDir": "build/dist", "module": "esnext", - "target": "es5", + "target": "es6", "lib": [ "es6", "dom" diff --git a/yarn.lock b/yarn.lock index b570f21b..e2523ae5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5398,6 +5398,11 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +fuse.js@^3.4.6: + version "3.4.6" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.6.tgz#545c3411fed88bf2e27c457cab6e73e7af697a45" + integrity sha512-H6aJY4UpLFwxj1+5nAvufom5b2BT2v45P1MkPvdGIK8fWjQx/7o6tTT1+ALV0yawQvbmvCF0ufl2et8eJ7v7Cg== + gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"