mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: delete a contact category with an undo
This commit is contained in:
@@ -171,6 +171,29 @@ live('live Stalwart: contacts and filters (0.16 and 1.0)', () => {
|
||||
|
||||
// 1.0 refuses to destroy a book that holds cards without
|
||||
// onDestroyRemoveContents (addressBookHasContents).
|
||||
it('deletes a category from its contacts and puts it back on Undo', async () => {
|
||||
const tag = `live-cat-${RUN}`;
|
||||
const one = await createContact({
|
||||
'@type': 'Card', version: '1.0', kind: 'individual', name: { full: `Live Cat One ${RUN}` },
|
||||
keywords: { [tag]: true, keep: true },
|
||||
} as Partial<ContactCard>, bookId);
|
||||
const two = await createContact({
|
||||
'@type': 'Card', version: '1.0', kind: 'individual', name: { full: `Live Cat Two ${RUN}` },
|
||||
keywords: { [tag]: true },
|
||||
} as Partial<ContactCard>, bookId);
|
||||
createdCards.push(one.id, two.id);
|
||||
await useContactsStore.getState().fetchContacts();
|
||||
|
||||
const ids = await useContactsStore.getState().removeKeyword(tag);
|
||||
expect(ids.sort()).toEqual([one.id, two.id].sort());
|
||||
expect((await getContact(one.id))?.keywords ?? {}).toEqual({ keep: true });
|
||||
expect((await getContact(two.id))?.keywords?.[tag]).toBeFalsy();
|
||||
|
||||
await useContactsStore.getState().addKeyword(tag, ids);
|
||||
expect((await getContact(one.id))?.keywords).toEqual({ [tag]: true, keep: true });
|
||||
expect((await getContact(two.id))?.keywords).toEqual({ [tag]: true });
|
||||
}, 60_000);
|
||||
|
||||
it('deletes an address book that still holds contacts', async () => {
|
||||
const book = await createAddressBook(`live-contacts-${RUN}-full`);
|
||||
const card = await createContact({ '@type': 'Card', version: '1.0', name: { full: `Live Book ${RUN}` } } as Partial<ContactCard>, book.id);
|
||||
|
||||
@@ -6,8 +6,9 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check,
|
||||
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check, Trash2,
|
||||
} from '../icons';
|
||||
import { toast } from '../../stores/toast-store';
|
||||
import type { RootStackParamList } from '../../navigation/types';
|
||||
import type { AddressBook } from '../../api/types';
|
||||
import type { ContactCategory } from '../../stores/contacts-store';
|
||||
@@ -46,6 +47,8 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
const renameKeyword = useContactsStore((s) => s.renameKeyword);
|
||||
const removeKeyword = useContactsStore((s) => s.removeKeyword);
|
||||
const addKeyword = useContactsStore((s) => s.addKeyword);
|
||||
|
||||
const totalCount = React.useMemo(
|
||||
() => contacts.filter((c) => !isGroup(c)).length,
|
||||
@@ -150,6 +153,31 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// A category can be put back as it was, so it goes at once with an Undo
|
||||
// (webmail 1.19 useDeleteCategory).
|
||||
const deleteCategory = async (keyword: string) => {
|
||||
if (renameBusy) return;
|
||||
setRenameBusy(true);
|
||||
let ids: string[];
|
||||
try {
|
||||
ids = await removeKeyword(keyword);
|
||||
setRenaming(null);
|
||||
} catch {
|
||||
toast.error(t('contacts.category_delete_failed', "Couldn't delete the category"));
|
||||
return;
|
||||
} finally {
|
||||
setRenameBusy(false);
|
||||
}
|
||||
toast.success(t('contacts.category_deleted', 'Category "{name}" deleted', { name: keyword }), {
|
||||
action: {
|
||||
label: t('contacts.undo', 'Undo'),
|
||||
onPress: () => {
|
||||
void addKeyword(keyword, ids).catch(() => toast.error(t('contacts.toast.error_update', 'Failed to update contact')));
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderBook = (book: AddressBook & { count: number }) => (
|
||||
<CategoryRow
|
||||
key={book.id}
|
||||
@@ -271,6 +299,15 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
>
|
||||
<Check size={16} color={c.primary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => { void deleteCategory(kw.keyword); }}
|
||||
hitSlop={6}
|
||||
style={styles.renameBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('contacts.delete_category', 'Delete category')}
|
||||
>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setRenaming(null)}
|
||||
hitSlop={6}
|
||||
|
||||
@@ -591,6 +591,32 @@ describe('contacts-store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting a category (removeKeyword / addKeyword)', () => {
|
||||
it('takes the category off every card that has it and puts it back on those on Undo', async () => {
|
||||
useContactsStore.setState({
|
||||
contacts: [
|
||||
card('c1', { keywords: { Gone: true, Keep: true } }),
|
||||
card('c2', { keywords: { Gone: true } }),
|
||||
card('c3', { keywords: { Other: true } }),
|
||||
],
|
||||
selectedCategory: { type: 'keyword', keyword: 'Gone' },
|
||||
});
|
||||
mockUpdateContact.mockResolvedValue(undefined);
|
||||
const ids = await useContactsStore.getState().removeKeyword('Gone');
|
||||
expect(ids).toEqual(['c1', 'c2']);
|
||||
expect(mockUpdateContact).toHaveBeenCalledWith('c1', { keywords: { Keep: true } }, undefined);
|
||||
expect(mockUpdateContact).toHaveBeenCalledWith('c2', { keywords: {} }, undefined);
|
||||
expect(mockUpdateContact).toHaveBeenCalledTimes(2);
|
||||
expect(useContactsStore.getState().selectedCategory).toEqual({ type: 'all' });
|
||||
|
||||
mockUpdateContact.mockClear();
|
||||
await useContactsStore.getState().addKeyword('Gone', ids);
|
||||
expect(mockUpdateContact).toHaveBeenCalledWith('c1', { keywords: { Keep: true, Gone: true } }, undefined);
|
||||
expect(mockUpdateContact).toHaveBeenCalledWith('c2', { keywords: { Gone: true } }, undefined);
|
||||
expect(useContactsStore.getState().contacts.find((c) => c.id === 'c3')?.keywords).toEqual({ Other: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('autocomplete', () => {
|
||||
it('merges contacts, groups and recent recipients without duplicates', async () => {
|
||||
useContactsStore.setState({
|
||||
|
||||
@@ -132,6 +132,13 @@ export interface ContactsState {
|
||||
removeContactKeyword: (id: string, keyword: string) => Promise<void>;
|
||||
addKeywordToContacts: (ids: string[], keyword: string) => Promise<void>;
|
||||
renameKeyword: (oldKeyword: string, newKeyword: string) => Promise<void>;
|
||||
/**
|
||||
* Delete a category: take it off every contact that has it. Resolves to
|
||||
* the contacts it came off, for an Undo (webmail 1.19 removeKeyword).
|
||||
*/
|
||||
removeKeyword: (keyword: string) => Promise<string[]>;
|
||||
/** Put a category back on these contacts (the Undo of removeKeyword). */
|
||||
addKeyword: (keyword: string, contactIds: string[]) => Promise<void>;
|
||||
moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise<void>;
|
||||
|
||||
// Groups
|
||||
@@ -742,6 +749,31 @@ export const useContactsStore = create<ContactsState>()(
|
||||
}
|
||||
},
|
||||
|
||||
removeKeyword: async (keyword) => {
|
||||
const kw = keyword.trim();
|
||||
if (!kw) return [];
|
||||
const done: string[] = [];
|
||||
for (const contact of get().contacts.filter((c) => c.keywords?.[kw])) {
|
||||
const { [kw]: _gone, ...rest } = contact.keywords || {};
|
||||
// Through the normal update, so each contact goes to its own account.
|
||||
await get().updateContact(contact.id, { keywords: rest });
|
||||
done.push(contact.id);
|
||||
}
|
||||
const category = get().selectedCategory;
|
||||
if (category.type === 'keyword' && category.keyword === kw) get().setSelectedCategory({ type: 'all' });
|
||||
return done;
|
||||
},
|
||||
|
||||
addKeyword: async (keyword, contactIds) => {
|
||||
const kw = keyword.trim();
|
||||
if (!kw) return;
|
||||
for (const id of contactIds) {
|
||||
const contact = get().contacts.find((c) => c.id === id);
|
||||
if (!contact || contact.keywords?.[kw]) continue;
|
||||
await get().updateContact(id, { keywords: { ...(contact.keywords || {}), [kw]: true } });
|
||||
}
|
||||
},
|
||||
|
||||
moveContactsToAddressBook: async (ids, addressBookId) => {
|
||||
const target = bookTarget(addressBookId);
|
||||
const primaryAccountId = getContactsAccountId();
|
||||
|
||||
Reference in New Issue
Block a user