Implement contacts editing.
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 { 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/locale/en-gb";
|
||||
|
@ -14,12 +14,11 @@ import { routeResolver } from "./App";
|
|||
|
||||
import AppBarOverride from "./widgets/AppBarOverride";
|
||||
import LoadingIndicator from "./widgets/LoadingIndicator";
|
||||
import SearchableAddressBook from "./components/SearchableAddressBook";
|
||||
import ContactsMain from "./Contacts/Main";
|
||||
|
||||
import Journals from "./Journals";
|
||||
import Settings from "./Settings";
|
||||
import Debug from "./Debug";
|
||||
import Pim from "./Pim";
|
||||
|
||||
import * as EteSync from "etesync";
|
||||
|
||||
|
@ -38,8 +37,6 @@ export interface SyncInfoJournal {
|
|||
|
||||
export type SyncInfo = Map<string, SyncInfoJournal>;
|
||||
|
||||
const PimRouter = withRouter(Pim);
|
||||
|
||||
export default function SyncGate() {
|
||||
const etebase = useCredentials();
|
||||
const settings = useSelector((state: StoreState) => state.settings);
|
||||
|
@ -68,6 +65,7 @@ export default function SyncGate() {
|
|||
// FIXME: Shouldn't be here
|
||||
moment.locale(settings.locale);
|
||||
|
||||
// FIXME: remove
|
||||
const etesync = etebase as any;
|
||||
|
||||
return (
|
||||
|
@ -83,12 +81,6 @@ export default function SyncGate() {
|
|||
path={routeResolver.getRoute("pim")}
|
||||
>
|
||||
<AppBarOverride title="EteSync" />
|
||||
<PimRouter
|
||||
etesync={etesync}
|
||||
userInfo={userInfo}
|
||||
syncInfo={false as any}
|
||||
history={history}
|
||||
/>
|
||||
<Switch>
|
||||
<Route
|
||||
path={routeResolver.getRoute("pim")}
|
||||
|
@ -99,7 +91,7 @@ export default function SyncGate() {
|
|||
<Route
|
||||
path={routeResolver.getRoute("pim.contacts")}
|
||||
>
|
||||
<SearchableAddressBook onItemClick={() => 1} />
|
||||
<ContactsMain />
|
||||
</Route>
|
||||
<Route
|
||||
path={routeResolver.getRoute("pim.events")}
|
||||
|
|
|
@ -19,11 +19,11 @@ import IconSave from "@material-ui/icons/Save";
|
|||
|
||||
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||
|
||||
import { CachedCollection } from "../Pim/helpers";
|
||||
|
||||
import * as uuid from "uuid";
|
||||
import * as ICAL from "ical.js";
|
||||
|
||||
import * as EteSync from "etesync";
|
||||
|
||||
import { ContactType } from "../pim-types";
|
||||
|
||||
import { History } from "history";
|
||||
|
@ -117,7 +117,7 @@ const ValueTypeComponent = (props: ValueTypeComponentProps) => {
|
|||
};
|
||||
|
||||
interface PropsType {
|
||||
collections: EteSync.CollectionInfo[];
|
||||
collections: CachedCollection[];
|
||||
initialCollection?: string;
|
||||
item?: ContactType;
|
||||
onSave: (contact: ContactType, journalUid: string, originalContact?: ContactType) => Promise<void>;
|
||||
|
@ -229,7 +229,7 @@ class ContactEdit extends React.PureComponent<PropsType> {
|
|||
if (props.initialCollection) {
|
||||
this.state.journalUid = props.initialCollection;
|
||||
} 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.handleChange = this.handleChange.bind(this);
|
||||
|
@ -403,7 +403,7 @@ class ContactEdit extends React.PureComponent<PropsType> {
|
|||
onChange={this.handleInputChange}
|
||||
>
|
||||
{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>
|
||||
</FormControl>
|
||||
|
|
|
@ -21,11 +21,6 @@ interface PropsType {
|
|||
export default function SearchableAddressBook(props: PropsType) {
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
|
||||
const {
|
||||
entries,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const reg = new RegExp(searchQuery, "i");
|
||||
|
||||
return (
|
||||
|
@ -43,7 +38,7 @@ export default function SearchableAddressBook(props: PropsType) {
|
|||
</IconButton>
|
||||
}
|
||||
<IconSearch />
|
||||
<AddressBook entries={entries} filter={(ent: ContactType) => ent.fn?.match(reg)} {...rest} />
|
||||
<AddressBook filter={(ent: ContactType) => ent.fn?.match(reg)} {...props} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@ export const PRODID = "-//iCal.js EteSync iOS";
|
|||
|
||||
export interface PimType {
|
||||
uid: string;
|
||||
collectionUid?: string;
|
||||
itemUid?: string;
|
||||
toIcal(): string;
|
||||
clone(): PimType;
|
||||
}
|
||||
|
@ -54,6 +56,9 @@ export function parseString(content: string) {
|
|||
}
|
||||
|
||||
export class EventType extends ICAL.Event implements PimType {
|
||||
public collectionUid?: string;
|
||||
public itemUid?: string;
|
||||
|
||||
public static isEvent(comp: ICAL.Component) {
|
||||
return !!comp.getFirstSubcomponent("vevent");
|
||||
}
|
||||
|
@ -155,6 +160,9 @@ export enum TaskPriorityType {
|
|||
export const TaskTags = ["Work", "Home"];
|
||||
|
||||
export class TaskType extends EventType {
|
||||
public collectionUid?: string;
|
||||
public itemUid?: string;
|
||||
|
||||
public static fromVCalendar(comp: ICAL.Component) {
|
||||
const task = new TaskType(comp.getFirstSubcomponent("vtodo"));
|
||||
// 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 {
|
||||
public comp: ICAL.Component;
|
||||
public collectionUid?: string;
|
||||
public itemUid?: string;
|
||||
|
||||
|
||||
public static parse(content: string) {
|
||||
return new ContactType(parseString(content));
|
||||
|
|
Loading…
Reference in New Issue