Merge Contacts: Add basic group editing functionality

Merge of #176
master
Tom Hacohen 4 years ago committed by GitHub
commit e9cbb9bf37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -41,10 +41,16 @@ export const routeResolver = new RouteResolver({
contacts: {
_id: {
_base: ":itemUid",
edit: "edit",
edit: {
contact: "contact",
group: "group",
},
log: "log",
},
new: "new",
new: {
contact: "contact",
group: "group",
},
},
events: {
_id: {

@ -27,6 +27,7 @@ import * as ICAL from "ical.js";
import { ContactType } from "../pim-types";
import { History } from "history";
import Autocomplete from "@material-ui/lab/Autocomplete";
const telTypes = [
{ type: "Home" },
@ -124,6 +125,7 @@ interface PropsType {
onDelete: (contact: ContactType, collectionUid: string) => void;
onCancel: () => void;
history: History<any>;
allGroups: ContactType[];
}
class ContactEdit extends React.PureComponent<PropsType> {
@ -145,6 +147,9 @@ class ContactEdit extends React.PureComponent<PropsType> {
collectionUid: string;
showDeleteDialog: boolean;
collectionGroups: {};
newGroups: string[];
originalGroups: string[];
};
constructor(props: PropsType) {
@ -167,6 +172,9 @@ class ContactEdit extends React.PureComponent<PropsType> {
collectionUid: "",
showDeleteDialog: false,
collectionGroups: {},
newGroups: [],
originalGroups: [],
};
if (this.props.item !== undefined) {
@ -231,9 +239,22 @@ class ContactEdit extends React.PureComponent<PropsType> {
} else if (props.collections[0]) {
this.state.collectionUid = props.collections[0].collection.uid;
}
this.state.collectionGroups = this.getCollectionGroups(this.state.collectionUid);
Object.values(this.state.collectionGroups).forEach((group: ContactType) => {
if (group.members.includes(this.state.uid)) {
this.state.newGroups.push(group.fn);
this.state.originalGroups.push(group.fn);
}
});
this.onSubmit = this.onSubmit.bind(this);
this.addMetadata = this.addMetadata.bind(this);
this.getCollectionGroups = this.getCollectionGroups.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleCollectionChange = this.handleCollectionChange.bind(this);
this.reloadGroupSuggestions = this.reloadGroupSuggestions.bind(this);
this.handleValueTypeChange = this.handleValueTypeChange.bind(this);
this.addValueType = this.addValueType.bind(this);
this.removeValueType = this.removeValueType.bind(this);
@ -274,11 +295,34 @@ class ContactEdit extends React.PureComponent<PropsType> {
});
}
public handleChange(name: string, value: string) {
public handleChange(name: string, value: string | string[]) {
this.setState({
[name]: value,
});
}
public getCollectionGroups(collectionUid: string) {
const groups = {};
this.props.allGroups.forEach((group) => {
if (collectionUid === group.collectionUid) {
groups[group.fn] = group;
}
});
return groups;
}
public reloadGroupSuggestions(collectionUid: string) {
this.setState({
collectionGroups: this.getCollectionGroups(collectionUid),
newGroups: [],
});
}
public handleCollectionChange(contact: any) {
const name = contact.target.name;
const value = contact.target.value;
this.reloadGroupSuggestions(value);
this.handleChange(name, value);
}
public handleInputChange(contact: any) {
@ -287,6 +331,17 @@ class ContactEdit extends React.PureComponent<PropsType> {
this.handleChange(name, value);
}
public addMetadata(item: ContactType, uid: string, isGroup: boolean) {
const comp = item.comp;
comp.updatePropertyWithValue("prodid", "-//iCal.js EteSync Web");
comp.updatePropertyWithValue("version", "4.0");
comp.updatePropertyWithValue("uid", uid);
comp.updatePropertyWithValue("rev", ICAL.Time.now());
if (isGroup) {
comp.updatePropertyWithValue("kind", "group");
}
}
public onSubmit(e: React.FormEvent<any>) {
e.preventDefault();
@ -297,10 +352,33 @@ class ContactEdit extends React.PureComponent<PropsType> {
;
const comp = contact.comp;
comp.updatePropertyWithValue("prodid", "-//iCal.js EteSync Web");
comp.updatePropertyWithValue("version", "4.0");
comp.updatePropertyWithValue("uid", this.state.uid);
comp.updatePropertyWithValue("rev", ICAL.Time.now());
this.addMetadata(contact, this.state.uid, false);
// Add new groups
this.state.newGroups.forEach((group) => {
if (!this.state.collectionGroups[group]) {
const newGroup = new ContactType(new ICAL.Component(["vcard", [], []]));
this.addMetadata(newGroup, uuid.v4(), true);
newGroup.comp.updatePropertyWithValue("fn", group.trim());
newGroup.comp.updatePropertyWithValue("member", `urn:uuid:${this.state.uid}`);
this.props.onSave(newGroup, this.state.collectionUid, undefined);
} else if (!this.state.originalGroups[group]) {
const oldGroup = this.state.collectionGroups[group];
const updatedGroup = oldGroup.clone();
updatedGroup.comp.addPropertyWithValue("member", `urn:uuid:${this.state.uid}`);
this.props.onSave(updatedGroup, this.state.collectionUid, oldGroup);
}
});
// Remove deleted groups
this.state.originalGroups.filter((x) => !this.state.newGroups.includes(x)).forEach((removed) => {
const deletedGroup = this.state.collectionGroups[removed];
const updatedGroup = deletedGroup.clone();
const members = updatedGroup.members.filter((uid: string) => uid !== this.state.uid);
updatedGroup.comp.removeAllProperties("member");
members.forEach((m: string) => updatedGroup.comp.addPropertyWithValue("member", `urn:uuid:${m}`));
this.props.onSave(updatedGroup, this.state.collectionUid, deletedGroup);
});
const lastName = this.state.lastName.trim();
const firstName = this.state.firstName.trim();
@ -359,7 +437,6 @@ class ContactEdit extends React.PureComponent<PropsType> {
setProperty("title", this.state.title);
setProperty("note", this.state.note);
this.props.onSave(contact, this.state.collectionUid, this.props.item)
.then(() => {
this.props.history.goBack();
@ -400,7 +477,7 @@ class ContactEdit extends React.PureComponent<PropsType> {
<Select
name="collectionUid"
value={this.state.collectionUid}
onChange={this.handleInputChange}
onChange={this.handleCollectionChange}
>
{this.props.collections.map((x) => (
<MenuItem key={x.collection.uid} value={x.collection.uid}>{x.metadata.name}</MenuItem>
@ -565,6 +642,24 @@ class ContactEdit extends React.PureComponent<PropsType> {
value={this.state.note}
onChange={this.handleInputChange}
/>
<Autocomplete
style={styles.fullWidth}
freeSolo
multiple
clearOnBlur
selectOnFocus
options={Object.keys(this.state.collectionGroups)}
value={this.state.newGroups}
onChange={(_e, value) => this.handleChange("newGroups", value)}
renderInput={(params) => (
<TextField
{...params}
variant="standard"
label="Groups"
fullWidth
/>
)}
/>
<div style={styles.submit}>
<Button

@ -0,0 +1,288 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";
import FormControl from "@material-ui/core/FormControl";
import InputLabel from "@material-ui/core/InputLabel";
import * as colors from "@material-ui/core/colors";
import IconDelete from "@material-ui/icons/Delete";
import IconCancel from "@material-ui/icons/Clear";
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 { ContactType } from "../pim-types";
import { History } from "history";
class ValueType {
public type: string;
public value: string;
constructor(type?: string, value?: string) {
this.type = type ? type : "home";
this.value = value ? value : "";
}
}
interface PropsType {
collections: CachedCollection[];
initialCollection?: string;
item?: ContactType;
onSave: (contact: ContactType, collectionUid: string, originalContact?: ContactType) => Promise<void>;
onDelete: (contact: ContactType, collectionUid: string) => void;
onCancel: () => void;
history: History<any>;
allGroups: ContactType[];
}
class GroupEdit extends React.PureComponent<PropsType> {
public state: {
uid: string;
fn: string;
collectionUid: string;
showDeleteDialog: boolean;
collectionGroups: {};
showError: boolean;
};
constructor(props: PropsType) {
super(props);
this.state = {
uid: "",
fn: "",
collectionUid: "",
showDeleteDialog: false,
collectionGroups: {},
showError: false,
};
if (this.props.item !== undefined) {
const group = this.props.item;
this.state.uid = group.uid;
this.state.fn = group.fn;
} else {
this.state.uid = uuid.v4();
}
if (props.initialCollection) {
this.state.collectionUid = props.initialCollection;
} else if (props.collections[0]) {
this.state.collectionUid = props.collections[0].collection.uid;
}
this.state.collectionGroups = this.getCollectionGroups(this.state.collectionUid);
this.onSubmit = this.onSubmit.bind(this);
this.getCollectionGroups = this.getCollectionGroups.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleCollectionChange = this.handleCollectionChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleValueTypeChange = this.handleValueTypeChange.bind(this);
this.addValueType = this.addValueType.bind(this);
this.removeValueType = this.removeValueType.bind(this);
this.onDeleteRequest = this.onDeleteRequest.bind(this);
}
public addValueType(name: string, _type?: string) {
const type = _type ? _type : "home";
this.setState((prevState) => {
const newArray = prevState[name].slice(0);
newArray.push(new ValueType(type));
return {
...prevState,
[name]: newArray,
};
});
}
public removeValueType(name: string, idx: number) {
this.setState((prevState) => {
const newArray = prevState[name].slice(0);
newArray.splice(idx, 1);
return {
...prevState,
[name]: newArray,
};
});
}
public handleValueTypeChange(name: string, idx: number, value: ValueType) {
this.setState((prevState) => {
const newArray = prevState[name].slice(0);
newArray[idx] = value;
return {
...prevState,
[name]: newArray,
};
});
}
public handleChange(name: string, value: string) {
this.setState({
[name]: value,
});
}
public getCollectionGroups(collectionUid: string) {
const groups = {};
this.props.allGroups.forEach((group) => {
if (collectionUid === group.collectionUid) {
groups[group.fn] = null;
}
});
return groups;
}
public handleCollectionChange(contact: any) {
const name = contact.target.name;
const collectionUid: string = contact.target.value;
this.handleChange(name, collectionUid);
this.setState({ "collectionGroups": this.getCollectionGroups(collectionUid) });
}
public handleInputChange(contact: any) {
const name = contact.target.name;
const value = contact.target.value;
this.handleChange(name, value);
}
public onSubmit(e: React.FormEvent<any>) {
e.preventDefault();
const nameUsed = this.state.fn in this.state.collectionGroups;
if ((this.props.item && this.state.fn !== this.props.item.fn && nameUsed) || (!this.props.item && nameUsed)) {
this.setState({ showError: true });
return;
}
const group = (this.props.item) ?
this.props.item.clone()
:
new ContactType(new ICAL.Component(["vcard", [], []]))
;
const comp = group.comp;
comp.updatePropertyWithValue("prodid", "-//iCal.js EteSync Web");
comp.updatePropertyWithValue("version", "4.0");
comp.updatePropertyWithValue("uid", this.state.uid);
comp.updatePropertyWithValue("rev", ICAL.Time.now());
comp.updatePropertyWithValue("kind", "group");
comp.updatePropertyWithValue("fn", this.state.fn.trim());
this.props.onSave(group, this.state.collectionUid, this.props.item)
.then(() => {
this.props.history.goBack();
});
}
public onDeleteRequest() {
this.setState({
showDeleteDialog: true,
});
}
public render() {
const styles = {
form: {
},
fullWidth: {
width: "100%",
boxSizing: "border-box" as any,
},
submit: {
marginTop: 40,
marginBottom: 20,
textAlign: "right" as any,
},
};
return (
<React.Fragment>
<h2>
{this.props.item ? "Edit Group" : "New Group"}
</h2>
<form style={styles.form} onSubmit={this.onSubmit}>
<FormControl disabled={this.props.item !== undefined} style={styles.fullWidth}>
<InputLabel>
Saving to
</InputLabel>
<Select
name="collectionUid"
value={this.state.collectionUid}
onChange={this.handleCollectionChange}
>
{this.props.collections.map((x) => (
<MenuItem key={x.collection.uid} value={x.collection.uid}>{x.metadata.name}</MenuItem>
))}
</Select>
</FormControl>
<TextField
name="fn"
placeholder="Name"
error={this.state.showError}
helperText="Group names must be unique"
style={{ marginTop: "2rem", ...styles.fullWidth }}
value={this.state.fn}
onChange={this.handleInputChange}
/>
<div style={styles.submit}>
<Button
variant="contained"
onClick={this.props.onCancel}
>
<IconCancel style={{ marginRight: 8 }} />
Cancel
</Button>
{this.props.item &&
<Button
variant="contained"
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
onClick={this.onDeleteRequest}
>
<IconDelete style={{ marginRight: 8 }} />
Delete
</Button>
}
<Button
type="submit"
variant="contained"
color="secondary"
style={{ marginLeft: 15 }}
disabled={this.state.fn.length === 0}
>
<IconSave style={{ marginRight: 8 }} />
Save
</Button>
</div>
</form>
<ConfirmationDialog
title="Delete Confirmation"
labelOk="Delete"
open={this.state.showDeleteDialog}
onOk={() => this.props.onDelete(this.props.item!, this.props.initialCollection!)}
onCancel={() => this.setState({ showDeleteDialog: false })}
>
Are you sure you would like to delete this group?
</ConfirmationDialog>
</React.Fragment>
);
}
}
export default GroupEdit;

@ -18,6 +18,7 @@ import SearchableAddressBook from "./SearchableAddressBook";
import Contact from "./Contact";
import LoadingIndicator from "../widgets/LoadingIndicator";
import ContactEdit from "./ContactEdit";
import GroupEdit from "./GroupEdit";
import PageNotFound, { PageNotFoundRoute } from "../PageNotFound";
import { CachedCollection, getItemNavigationUid, getDecryptCollectionsFunction, getDecryptItemsFunction, PimFab, itemSave, itemDelete } from "../Pim/helpers";
@ -79,6 +80,8 @@ export default function ContactsMain() {
}
}
const allGroups = flatEntries.filter((x) => x.group);
const styles = {
button: {
marginLeft: theme.spacing(1),
@ -99,15 +102,31 @@ export default function ContactsMain() {
onItemClick={(item) => history.push(
routeResolver.getRoute("pim.contacts._id", { itemUid: getItemNavigationUid(item) })
)}
onNewGroupClick={() => history.push(
routeResolver.getRoute("pim.contacts.new.group")
)}
/>
<PimFab
onClick={() => history.push(
routeResolver.getRoute("pim.contacts.new")
routeResolver.getRoute("pim.contacts.new.contact")
)}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.contacts.new")}
path={routeResolver.getRoute("pim.contacts.new.group")}
exact
>
<GroupEdit
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
allGroups={allGroups}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.contacts.new.contact")}
exact
>
<ContactEdit
@ -116,6 +135,7 @@ export default function ContactsMain() {
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
allGroups={allGroups}
/>
</Route>
<Route
@ -144,23 +164,39 @@ export default function ContactsMain() {
const collection = collections!.find((x) => x.uid === colUid)!;
const readOnly = collection.accessLevel === Etebase.CollectionAccessLevel.ReadOnly;
const path = `pim.contacts._id.edit.${item.group ? "group" : "contact"}`;
return (
<Switch>
<Route
path={routeResolver.getRoute("pim.contacts._id.edit")}
path={routeResolver.getRoute(path)}
exact
>
<ContactEdit
key={itemUid}
initialCollection={item.collectionUid}
item={item}
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
/>
{item.group ?
<GroupEdit
key={itemUid}
initialCollection={item.collectionUid}
item={item}
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
allGroups={allGroups}
/>
:
<ContactEdit
key={itemUid}
initialCollection={item.collectionUid}
item={item}
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
allGroups={allGroups}
/>
}
</Route>
<Route
path={routeResolver.getRoute("pim.contacts._id")}
@ -184,7 +220,7 @@ export default function ContactsMain() {
disabled={readOnly}
style={{ ...styles.button, marginLeft: 15 }}
onClick={() =>
history.push(routeResolver.getRoute("pim.contacts._id.edit", { itemUid: getItemNavigationUid(item) }))
history.push(routeResolver.getRoute(path, { itemUid: getItemNavigationUid(item) }))
}
>
<IconEdit style={styles.leftIcon} />

@ -22,6 +22,7 @@ const useStyles = makeStyles((theme) => ({
interface PropsType {
entries: ContactType[];
onItemClick: (contact: ContactType) => void;
onNewGroupClick: () => void;
}
export default function SearchableAddressBook(props: PropsType) {
@ -66,6 +67,8 @@ export default function SearchableAddressBook(props: PropsType) {
groups={groups}
filterByGroup={filterByGroup}
setFilterByGroup={setFilterByGroup}
newGroup={props.onNewGroupClick}
editGroup={props.onItemClick}
/>
</Grid>

@ -2,6 +2,10 @@ import * as React from "react";
import InboxIcon from "@material-ui/icons/Inbox";
import LabelIcon from "@material-ui/icons/LabelOutlined";
import AddIcon from "@material-ui/icons/Add";
import EditIcon from "@material-ui/icons/EditOutlined";
import IconButton from "@material-ui/core/IconButton";
import { List, ListItem, ListSubheader } from "../widgets/List";
import { ContactType } from "../pim-types";
@ -12,19 +16,27 @@ interface ListItemPropsType {
primaryText: string;
filterByGroup: string | undefined;
setFilterByGroup: (group: string | undefined) => void;
editGroup: () => void;
}
function SidebarListItem(props: ListItemPropsType) {
const { name, icon, primaryText, filterByGroup } = props;
const { name, icon, primaryText, filterByGroup, editGroup } = props;
const handleClick = () => props.setFilterByGroup(name);
const selected = name === filterByGroup;
return (
<ListItem
onClick={handleClick}
selected={name === filterByGroup}
selected={selected}
leftIcon={icon}
primaryText={primaryText}
secondaryAction={name && selected &&
<IconButton onClick={editGroup}>
<EditIcon />
</IconButton>
}
/>
);
}
@ -33,10 +45,12 @@ interface PropsType {
groups: ContactType[];
filterByGroup: string | undefined;
setFilterByGroup: (group: string | undefined) => void;
newGroup: () => void;
editGroup: (group: ContactType) => void;
}
export default React.memo(function Sidebar(props: PropsType) {
const { groups, filterByGroup, setFilterByGroup } = props;
const { groups, filterByGroup, setFilterByGroup, newGroup, editGroup } = props;
const groupList = [...groups].sort((a, b) => a.fn.localeCompare(b.fn)).map((group) => (
<SidebarListItem
@ -46,6 +60,7 @@ export default React.memo(function Sidebar(props: PropsType) {
icon={<LabelIcon />}
filterByGroup={filterByGroup}
setFilterByGroup={setFilterByGroup}
editGroup={() => editGroup(group)}
/>
));
@ -57,9 +72,21 @@ export default React.memo(function Sidebar(props: PropsType) {
icon={<InboxIcon />}
filterByGroup={filterByGroup}
setFilterByGroup={setFilterByGroup}
editGroup={newGroup}
/>
<ListSubheader>Groups</ListSubheader>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<ListSubheader>
Groups
</ListSubheader>
<IconButton
edge="end"
onClick={newGroup}
>
<AddIcon />
</IconButton>
</div>
{groupList}
</List>
);

@ -6,6 +6,7 @@ import * as React from "react";
import { createStyles, makeStyles } from "@material-ui/core/styles";
import MuiList from "@material-ui/core/List";
import MuiListItem from "@material-ui/core/ListItem";
import MuiListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction";
import MuiListSubheader from "@material-ui/core/ListSubheader";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText";
@ -47,6 +48,7 @@ interface ListItemPropsType {
nestedItems?: React.ReactNode[];
selected?: boolean;
secondaryTextColor?: "initial" | "inherit" | "primary" | "secondary" | "textPrimary" | "textSecondary" | "error";
secondaryAction?: React.ReactNode;
}
export const ListItem = React.memo(function ListItem(_props: ListItemPropsType) {
@ -64,6 +66,7 @@ export const ListItem = React.memo(function ListItem(_props: ListItemPropsType)
nestedItems,
selected,
secondaryTextColor,
secondaryAction,
} = _props;
const extraProps = (onClick || href) ? {
@ -94,6 +97,11 @@ export const ListItem = React.memo(function ListItem(_props: ListItemPropsType)
{rightIcon}
</ListItemIcon>
)}
{secondaryAction && (
<MuiListItemSecondaryAction>
{secondaryAction}
</MuiListItemSecondaryAction>
)}
</MuiListItem>
{nestedItems && (
<List className={classes.nested} disablePadding>

Loading…
Cancel
Save