Implement contacts editing.

master
Tom Hacohen 4 years ago
parent 2edc95cce7
commit b796217cd1

@ -0,0 +1,225 @@
// SPDX-FileCopyrightText: © 2020 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Switch, Route, useHistory } from "react-router";
import * as Etebase from "etebase";
import { Button, useTheme } from "@material-ui/core";
import IconEdit from "@material-ui/icons/Edit";
import IconChangeHistory from "@material-ui/icons/ChangeHistory";
import { ContactType, PimType } from "../pim-types";
import { useCredentials } from "../credentials";
import { useItems, useCollections, getCollectionManager } from "../etebase-helpers";
import { routeResolver } from "../App";
import SearchableAddressBook from "../components/SearchableAddressBook";
import Contact from "../components/Contact";
import LoadingIndicator from "../widgets/LoadingIndicator";
import ContactEdit from "../components/ContactEdit";
import PageNotFound from "../PageNotFound";
import { CachedCollection, getItemNavigationUid, getDecryptCollectionsFunction, getDecryptItemsFunction } from "../Pim/helpers";
const colType = "etebase.vcard";
const decryptCollections = getDecryptCollectionsFunction(colType);
const decryptItems = getDecryptItemsFunction(colType, ContactType.parse);
export default function ContactsMain() {
const [entries, setEntries] = React.useState<Map<string, Map<string, ContactType>>>();
const [cachedCollections, setCachedCollections] = React.useState<CachedCollection[]>();
const theme = useTheme();
const history = useHistory();
const etebase = useCredentials()!;
const collections = useCollections(etebase, colType);
const items = useItems(etebase, colType);
React.useEffect(() => {
if (items) {
decryptItems(items)
.then((entries) => setEntries(entries));
// FIXME: handle failure to decrypt items
}
if (collections) {
decryptCollections(collections)
.then((entries) => setCachedCollections(entries));
// FIXME: handle failure to decrypt collections
}
}, [items, collections]);
if (!entries || !cachedCollections) {
return (
<LoadingIndicator />
);
}
async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise<void> {
const itemUid = originalItem?.itemUid;
const colMgr = getCollectionManager(etebase);
const collection = collections!.find((x) => x.uid === collectionUid)!;
const itemMgr = colMgr.getItemManager(collection);
const mtime = (new Date()).getUTCMilliseconds();
const content = item.toIcal();
let eteItem;
if (itemUid) {
// Existing item
eteItem = items!.get(collectionUid)?.get(itemUid)!;
await eteItem.setContent(content);
const meta = await eteItem.getMeta();
meta.mtime = mtime;
await eteItem.setMeta(meta);
} else {
// New
const meta: Etebase.CollectionItemMetadata = {
mtime,
name: item.uid,
};
eteItem = await itemMgr.create(meta, content);
}
await itemMgr.batch([eteItem]);
}
async function onItemDelete(item: PimType, collectionUid: string) {
const itemUid = item.itemUid!;
const colMgr = getCollectionManager(etebase);
const collection = collections!.find((x) => x.uid === collectionUid)!;
const itemMgr = colMgr.getItemManager(collection);
const eteItem = items!.get(collectionUid)?.get(itemUid)!;
const mtime = (new Date()).getUTCMilliseconds();
const meta = await eteItem.getMeta();
meta.mtime = mtime;
await eteItem.setMeta(meta);
await eteItem.delete();
await itemMgr.batch([eteItem]);
history.push(routeResolver.getRoute("pim.contacts"));
}
function onCancel() {
history.goBack();
}
const flatEntries = [];
for (const col of entries.values()) {
for (const item of col.values()) {
flatEntries.push(item);
}
}
const styles = {
button: {
marginLeft: theme.spacing(1),
},
leftIcon: {
marginRight: theme.spacing(1),
},
};
return (
<Switch>
<Route
path={routeResolver.getRoute("pim.contacts")}
exact
>
<SearchableAddressBook
entries={flatEntries}
onItemClick={(item) => history.push(
routeResolver.getRoute("pim.contacts._id", { itemUid: getItemNavigationUid(item) })
)}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.contacts.new")}
exact
>
<ContactEdit
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.contacts._id")}
render={({ match }) => {
const [colUid, itemUid] = match.params.itemUid.split("|");
const item = entries.get(colUid)?.get(itemUid);
if (!item) {
return (<PageNotFound />);
}
/* FIXME:
const collection = collections!.find((x) => x.uid === colUid)!;
const readOnly = collection.accessLevel;
*/
const readOnly = false;
return (
<Switch>
<Route
path={routeResolver.getRoute("pim.contacts._id.edit")}
exact
>
<ContactEdit
key={itemUid}
initialCollection={item.collectionUid}
item={item}
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.contacts._id.log")}
>
<h1>Not currently implemented.</h1>
</Route>
<Route
path={routeResolver.getRoute("pim.contacts._id")}
exact
>
<div style={{ textAlign: "right", marginBottom: 15 }}>
<Button
variant="contained"
style={styles.button}
onClick={() =>
history.push(routeResolver.getRoute("pim.contacts._id.log", { itemUid: getItemNavigationUid(item) }))
}
>
<IconChangeHistory style={styles.leftIcon} />
Change History
</Button>
<Button
color="secondary"
variant="contained"
disabled={readOnly}
style={{ ...styles.button, marginLeft: 15 }}
onClick={() =>
history.push(routeResolver.getRoute("pim.contacts._id.edit", { itemUid: getItemNavigationUid(item) }))
}
>
<IconEdit style={styles.leftIcon} />
Edit
</Button>
</div>
<Contact item={item} />
</Route>
</Switch>
);
}}
/>
</Switch>
);
}

@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: © 2020 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import Container from "./widgets/Container";
export default function PageNotFound() {
return (
<Container>
<h1>404 Page Not Found</h1>
</Container>
);
}

@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: © 2020 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import memoize from "memoizee";
import * as Etebase from "etebase";
import { PimType } from "../pim-types";
export interface CachedCollection {
collection: Etebase.Collection;
metadata: Etebase.CollectionMetadata;
}
export function getItemNavigationUid(item: PimType) {
// Both collectionUid and itemUid are url safe
return `${item.collectionUid}|${item.itemUid}`;
}
export function getDecryptCollectionsFunction(_colType: string) {
return memoize(
async function (collections: Etebase.Collection[]) {
const entries: CachedCollection[] = [];
if (collections) {
for (const collection of collections) {
entries.push({
collection,
metadata: await collection.getMeta(),
});
}
}
return entries;
},
{ max: 1 }
);
}
export function getDecryptItemsFunction<T extends PimType>(_colType: string, parseFunc: (str: string) => T) {
return memoize(
async function (items: Map<string, Map<string, Etebase.CollectionItem>>) {
const entries: Map<string, Map<string, T>> = new Map();
if (items) {
for (const [colUid, col] of items.entries()) {
const cur = new Map();
entries.set(colUid, cur);
for (const item of col.values()) {
const contact = parseFunc(await item.getContent(Etebase.OutputFormat.String));
contact.collectionUid = colUid;
contact.itemUid = item.uid;
cur.set(item.uid, contact);
}
}
}
return entries;
},
{ max: 1 }
);
}

@ -3,7 +3,7 @@
import * as React from "react"; import * as React from "react";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { Route, Switch, Redirect, withRouter, useHistory } from "react-router"; import { Route, Switch, Redirect, useHistory } from "react-router";
import moment from "moment"; import moment from "moment";
import "moment/locale/en-gb"; import "moment/locale/en-gb";
@ -14,12 +14,11 @@ import { routeResolver } from "./App";
import AppBarOverride from "./widgets/AppBarOverride"; import AppBarOverride from "./widgets/AppBarOverride";
import LoadingIndicator from "./widgets/LoadingIndicator"; import LoadingIndicator from "./widgets/LoadingIndicator";
import SearchableAddressBook from "./components/SearchableAddressBook"; import ContactsMain from "./Contacts/Main";
import Journals from "./Journals"; import Journals from "./Journals";
import Settings from "./Settings"; import Settings from "./Settings";
import Debug from "./Debug"; import Debug from "./Debug";
import Pim from "./Pim";
import * as EteSync from "etesync"; import * as EteSync from "etesync";
@ -38,8 +37,6 @@ export interface SyncInfoJournal {
export type SyncInfo = Map<string, SyncInfoJournal>; export type SyncInfo = Map<string, SyncInfoJournal>;
const PimRouter = withRouter(Pim);
export default function SyncGate() { export default function SyncGate() {
const etebase = useCredentials(); const etebase = useCredentials();
const settings = useSelector((state: StoreState) => state.settings); const settings = useSelector((state: StoreState) => state.settings);
@ -68,6 +65,7 @@ export default function SyncGate() {
// FIXME: Shouldn't be here // FIXME: Shouldn't be here
moment.locale(settings.locale); moment.locale(settings.locale);
// FIXME: remove
const etesync = etebase as any; const etesync = etebase as any;
return ( return (
@ -83,12 +81,6 @@ export default function SyncGate() {
path={routeResolver.getRoute("pim")} path={routeResolver.getRoute("pim")}
> >
<AppBarOverride title="EteSync" /> <AppBarOverride title="EteSync" />
<PimRouter
etesync={etesync}
userInfo={userInfo}
syncInfo={false as any}
history={history}
/>
<Switch> <Switch>
<Route <Route
path={routeResolver.getRoute("pim")} path={routeResolver.getRoute("pim")}
@ -99,7 +91,7 @@ export default function SyncGate() {
<Route <Route
path={routeResolver.getRoute("pim.contacts")} path={routeResolver.getRoute("pim.contacts")}
> >
<SearchableAddressBook onItemClick={() => 1} /> <ContactsMain />
</Route> </Route>
<Route <Route
path={routeResolver.getRoute("pim.events")} path={routeResolver.getRoute("pim.events")}

@ -19,11 +19,11 @@ import IconSave from "@material-ui/icons/Save";
import ConfirmationDialog from "../widgets/ConfirmationDialog"; import ConfirmationDialog from "../widgets/ConfirmationDialog";
import { CachedCollection } from "../Pim/helpers";
import * as uuid from "uuid"; import * as uuid from "uuid";
import * as ICAL from "ical.js"; import * as ICAL from "ical.js";
import * as EteSync from "etesync";
import { ContactType } from "../pim-types"; import { ContactType } from "../pim-types";
import { History } from "history"; import { History } from "history";
@ -117,7 +117,7 @@ const ValueTypeComponent = (props: ValueTypeComponentProps) => {
}; };
interface PropsType { interface PropsType {
collections: EteSync.CollectionInfo[]; collections: CachedCollection[];
initialCollection?: string; initialCollection?: string;
item?: ContactType; item?: ContactType;
onSave: (contact: ContactType, journalUid: string, originalContact?: ContactType) => Promise<void>; onSave: (contact: ContactType, journalUid: string, originalContact?: ContactType) => Promise<void>;
@ -229,7 +229,7 @@ class ContactEdit extends React.PureComponent<PropsType> {
if (props.initialCollection) { if (props.initialCollection) {
this.state.journalUid = props.initialCollection; this.state.journalUid = props.initialCollection;
} else if (props.collections[0]) { } else if (props.collections[0]) {
this.state.journalUid = props.collections[0].uid; this.state.journalUid = props.collections[0].collection.uid;
} }
this.onSubmit = this.onSubmit.bind(this); this.onSubmit = this.onSubmit.bind(this);
this.handleChange = this.handleChange.bind(this); this.handleChange = this.handleChange.bind(this);
@ -403,7 +403,7 @@ class ContactEdit extends React.PureComponent<PropsType> {
onChange={this.handleInputChange} onChange={this.handleInputChange}
> >
{this.props.collections.map((x) => ( {this.props.collections.map((x) => (
<MenuItem key={x.uid} value={x.uid}>{x.displayName}</MenuItem> <MenuItem key={x.collection.uid} value={x.collection.uid}>{x.metadata.name}</MenuItem>
))} ))}
</Select> </Select>
</FormControl> </FormControl>

@ -21,11 +21,6 @@ interface PropsType {
export default function SearchableAddressBook(props: PropsType) { export default function SearchableAddressBook(props: PropsType) {
const [searchQuery, setSearchQuery] = React.useState(""); const [searchQuery, setSearchQuery] = React.useState("");
const {
entries,
...rest
} = props;
const reg = new RegExp(searchQuery, "i"); const reg = new RegExp(searchQuery, "i");
return ( return (
@ -43,7 +38,7 @@ export default function SearchableAddressBook(props: PropsType) {
</IconButton> </IconButton>
} }
<IconSearch /> <IconSearch />
<AddressBook entries={entries} filter={(ent: ContactType) => ent.fn?.match(reg)} {...rest} /> <AddressBook filter={(ent: ContactType) => ent.fn?.match(reg)} {...props} />
</React.Fragment> </React.Fragment>
); );
} }

@ -10,6 +10,8 @@ export const PRODID = "-//iCal.js EteSync iOS";
export interface PimType { export interface PimType {
uid: string; uid: string;
collectionUid?: string;
itemUid?: string;
toIcal(): string; toIcal(): string;
clone(): PimType; clone(): PimType;
} }
@ -54,6 +56,9 @@ export function parseString(content: string) {
} }
export class EventType extends ICAL.Event implements PimType { export class EventType extends ICAL.Event implements PimType {
public collectionUid?: string;
public itemUid?: string;
public static isEvent(comp: ICAL.Component) { public static isEvent(comp: ICAL.Component) {
return !!comp.getFirstSubcomponent("vevent"); return !!comp.getFirstSubcomponent("vevent");
} }
@ -155,6 +160,9 @@ export enum TaskPriorityType {
export const TaskTags = ["Work", "Home"]; export const TaskTags = ["Work", "Home"];
export class TaskType extends EventType { export class TaskType extends EventType {
public collectionUid?: string;
public itemUid?: string;
public static fromVCalendar(comp: ICAL.Component) { public static fromVCalendar(comp: ICAL.Component) {
const task = new TaskType(comp.getFirstSubcomponent("vtodo")); const task = new TaskType(comp.getFirstSubcomponent("vtodo"));
// FIXME: we need to clone it so it loads the correct timezone and applies it // FIXME: we need to clone it so it loads the correct timezone and applies it
@ -331,6 +339,9 @@ export class TaskType extends EventType {
export class ContactType implements PimType { export class ContactType implements PimType {
public comp: ICAL.Component; public comp: ICAL.Component;
public collectionUid?: string;
public itemUid?: string;
public static parse(content: string) { public static parse(content: string) {
return new ContactType(parseString(content)); return new ContactType(parseString(content));

Loading…
Cancel
Save