refactor(webui): use central reasoned buttons

This commit is contained in:
2026-07-21 12:38:45 +02:00
parent 5d560d4c58
commit 70ee3c0148

View File

@@ -1,11 +1,10 @@
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react"; import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type ButtonHTMLAttributes, type DragEvent as ReactDragEvent, type FormEvent } from "react"; import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import { import {
ApiError, ApiError,
Button, Button,
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
DisabledActionTooltip,
DismissibleAlert, DismissibleAlert,
ExplorerTree, ExplorerTree,
fetchAuthGroups, fetchAuthGroups,
@@ -604,19 +603,6 @@ function disabledReason(...conditions: Array<[boolean, string]>): string {
return conditions.find(([applies]) => applies)?.[1] ?? ""; return conditions.find(([applies]) => applies)?.[1] ?? "";
} }
type ReasonedButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost" | "danger";
disabledReason: string;
};
function ReasonedButton({ disabledReason, disabled, children, ...props }: ReasonedButtonProps) {
return (
<DisabledActionTooltip reason={disabledReason}>
<Button {...props} disabled={disabled || Boolean(disabledReason)}>{children}</Button>
</DisabledActionTooltip>
);
}
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) { export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
const [books, setBooks] = useState<AddressBook[]>([]); const [books, setBooks] = useState<AddressBook[]>([]);
const [addressLists, setAddressLists] = useState<AddressList[]>([]); const [addressLists, setAddressLists] = useState<AddressList[]>([]);
@@ -1775,27 +1761,27 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function renderSelectedBookActions() { function renderSelectedBookActions() {
return ( return (
<> <>
<ReasonedButton type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></ReasonedButton> <Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></Button>
<ReasonedButton type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></ReasonedButton> <Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
<ReasonedButton type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></ReasonedButton> <Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
<ReasonedButton type="button" title="Import vCard into selected address book" aria-label="Import vCard into selected address book" onClick={() => setImportOpen(true)} disabledReason={importBookReason}><Upload size={15} /></ReasonedButton> <Button type="button" title="Import vCard into selected address book" aria-label="Import vCard into selected address book" onClick={() => setImportOpen(true)} disabledReason={importBookReason}><Upload size={15} /></Button>
<ReasonedButton type="button" title="Export selected address book as vCard" aria-label="Export selected address book as vCard" onClick={() => void exportSelectedBook()} disabledReason={exportBookReason}><Download size={15} /></ReasonedButton> <Button type="button" title="Export selected address book as vCard" aria-label="Export selected address book as vCard" onClick={() => void exportSelectedBook()} disabledReason={exportBookReason}><Download size={15} /></Button>
<ReasonedButton type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></ReasonedButton> <Button type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></Button>
<ReasonedButton type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></ReasonedButton> <Button type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></Button>
<ReasonedButton type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</ReasonedButton> <Button type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</Button>
<ReasonedButton type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</ReasonedButton> <Button type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</Button>
{selectedList ? {selectedList ?
selectedList.deleted_at ? selectedList.deleted_at ?
<ReasonedButton type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></ReasonedButton> : <Button type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></Button> :
<> <>
<ReasonedButton type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></ReasonedButton> <Button type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></Button>
<ReasonedButton type="button" variant="danger" title="Delete address list" aria-label="Delete address list" disabledReason={deleteListReason(selectedList)} onClick={() => setConfirmState({ kind: "list", list: selectedList })}><Trash2 size={15} /></ReasonedButton> <Button type="button" variant="danger" title="Delete address list" aria-label="Delete address list" disabledReason={deleteListReason(selectedList)} onClick={() => setConfirmState({ kind: "list", list: selectedList })}><Trash2 size={15} /></Button>
</> : </> :
selectedBook?.deleted_at ? selectedBook?.deleted_at ?
<ReasonedButton type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></ReasonedButton> : <Button type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></Button> :
<> <>
<ReasonedButton type="button" title="Edit address book" aria-label="Edit address book" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && openEditBookDialog(selectedBook)}><Edit3 size={15} /></ReasonedButton> <Button type="button" title="Edit address book" aria-label="Edit address book" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && openEditBookDialog(selectedBook)}><Edit3 size={15} /></Button>
<ReasonedButton type="button" variant="danger" title="Delete address book" aria-label="Delete address book" disabledReason={selectedBook ? deleteBookReason(selectedBook) : "Select an address book before deleting."} onClick={() => selectedBook && setConfirmState({ kind: "book", book: selectedBook })}><Trash2 size={15} /></ReasonedButton> <Button type="button" variant="danger" title="Delete address book" aria-label="Delete address book" disabledReason={selectedBook ? deleteBookReason(selectedBook) : "Select an address book before deleting."} onClick={() => selectedBook && setConfirmState({ kind: "book", book: selectedBook })}><Trash2 size={15} /></Button>
</> </>
} }
</> </>
@@ -1841,11 +1827,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
{selectedContact.deleted_at ? {selectedContact.deleted_at ?
<ReasonedButton type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</ReasonedButton> : <Button type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</Button> :
<> <>
<ReasonedButton type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</ReasonedButton> <Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
<ReasonedButton type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</ReasonedButton> <Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
<ReasonedButton type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</ReasonedButton> <Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
</> </>
} }
</div> </div>
@@ -1862,13 +1848,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedContactListEntries.map((entry) => ( {selectedContactListEntries.map((entry) => (
<div className="address-membership-row" key={entry.id}> <div className="address-membership-row" key={entry.id}>
<p className="muted">{entryTargetLabel(entry)}</p> <p className="muted">{entryTargetLabel(entry)}</p>
<ReasonedButton <Button
type="button" type="button"
variant="danger" variant="danger"
onClick={() => void removeAddressListEntry(entry)} onClick={() => void removeAddressListEntry(entry)}
disabledReason={removeContactFromListReason()}> disabledReason={removeContactFromListReason()}>
<X size={15} /> Remove <X size={15} /> Remove
</ReasonedButton> </Button>
</div> </div>
))} ))}
</div> </div>
@@ -1985,9 +1971,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
{selectedList && {selectedList &&
<ReasonedButton type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</ReasonedButton> <Button type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</Button>
} }
<ReasonedButton type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</ReasonedButton> <Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button>
</div> </div>
</header> </header>
<div className="address-contact-toolbar"> <div className="address-contact-toolbar">
@@ -2017,8 +2003,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton> <Button type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<ReasonedButton type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</ReasonedButton> <Button type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</Button>
</> </>
}> }>
<form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}> <form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
@@ -2053,8 +2039,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton> <Button type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<ReasonedButton type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</ReasonedButton> <Button type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</Button>
</> </>
}> }>
<form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}> <form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
@@ -2080,8 +2066,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton> <Button type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<ReasonedButton type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</ReasonedButton> <Button type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</Button>
</> </>
}> }>
<form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}> <form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
@@ -2097,7 +2083,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section"> <section className="address-form-section">
<div className="address-form-section-heading"> <div className="address-form-section-heading">
<strong>Email addresses</strong> <strong>Email addresses</strong>
<ReasonedButton type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</ReasonedButton> <Button type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</Button>
</div> </div>
<div className="address-form-list"> <div className="address-form-list">
{contactForm.emails.map((email) => ( {contactForm.emails.map((email) => (
@@ -2105,7 +2091,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<label className="address-primary-choice"><input type="radio" name="address-primary-email" checked={email.is_primary} onChange={() => setPrimaryEmailRow(email.rowId)} /> Primary</label> <label className="address-primary-choice"><input type="radio" name="address-primary-email" checked={email.is_primary} onChange={() => setPrimaryEmailRow(email.rowId)} /> Primary</label>
<input value={email.label} placeholder="Label" onChange={(event) => updateEmailRow(email.rowId, { label: event.target.value })} /> <input value={email.label} placeholder="Label" onChange={(event) => updateEmailRow(email.rowId, { label: event.target.value })} />
<input type="email" value={email.email} placeholder="Email address" onChange={(event) => updateEmailRow(email.rowId, { email: event.target.value })} /> <input type="email" value={email.email} placeholder="Email address" onChange={(event) => updateEmailRow(email.rowId, { email: event.target.value })} />
<ReasonedButton type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></ReasonedButton> <Button type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></Button>
</div> </div>
))} ))}
</div> </div>
@@ -2114,7 +2100,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section"> <section className="address-form-section">
<div className="address-form-section-heading"> <div className="address-form-section-heading">
<strong>Phone numbers</strong> <strong>Phone numbers</strong>
<ReasonedButton type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</ReasonedButton> <Button type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</Button>
</div> </div>
<div className="address-form-list"> <div className="address-form-list">
{contactForm.phones.map((phone) => ( {contactForm.phones.map((phone) => (
@@ -2122,7 +2108,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<label className="address-primary-choice"><input type="radio" name="address-primary-phone" checked={phone.is_primary} onChange={() => setPrimaryPhoneRow(phone.rowId)} /> Primary</label> <label className="address-primary-choice"><input type="radio" name="address-primary-phone" checked={phone.is_primary} onChange={() => setPrimaryPhoneRow(phone.rowId)} /> Primary</label>
<input value={phone.label} placeholder="Label" onChange={(event) => updatePhoneRow(phone.rowId, { label: event.target.value })} /> <input value={phone.label} placeholder="Label" onChange={(event) => updatePhoneRow(phone.rowId, { label: event.target.value })} />
<input value={phone.phone} placeholder="Phone number" onChange={(event) => updatePhoneRow(phone.rowId, { phone: event.target.value })} /> <input value={phone.phone} placeholder="Phone number" onChange={(event) => updatePhoneRow(phone.rowId, { phone: event.target.value })} />
<ReasonedButton type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></ReasonedButton> <Button type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></Button>
</div> </div>
))} ))}
</div> </div>
@@ -2131,7 +2117,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<section className="address-form-section"> <section className="address-form-section">
<div className="address-form-section-heading"> <div className="address-form-section-heading">
<strong>Postal addresses</strong> <strong>Postal addresses</strong>
<ReasonedButton type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</ReasonedButton> <Button type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</Button>
</div> </div>
<div className="address-form-list"> <div className="address-form-list">
{contactForm.postal_addresses.map((address) => ( {contactForm.postal_addresses.map((address) => (
@@ -2143,7 +2129,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<input value={address.locality} placeholder="Locality" onChange={(event) => updatePostalAddressRow(address.rowId, { locality: event.target.value })} /> <input value={address.locality} placeholder="Locality" onChange={(event) => updatePostalAddressRow(address.rowId, { locality: event.target.value })} />
<input value={address.region} placeholder="Region" onChange={(event) => updatePostalAddressRow(address.rowId, { region: event.target.value })} /> <input value={address.region} placeholder="Region" onChange={(event) => updatePostalAddressRow(address.rowId, { region: event.target.value })} />
<input value={address.country} placeholder="Country" onChange={(event) => updatePostalAddressRow(address.rowId, { country: event.target.value })} /> <input value={address.country} placeholder="Country" onChange={(event) => updatePostalAddressRow(address.rowId, { country: event.target.value })} />
<ReasonedButton type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></ReasonedButton> <Button type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></Button>
</div> </div>
))} ))}
</div> </div>
@@ -2160,7 +2146,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
closeDisabled={saving} closeDisabled={saving}
className="address-member-dialog" className="address-member-dialog"
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={<ReasonedButton type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</ReasonedButton>}> footer={<Button type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</Button>}>
<div className="address-member-picker"> <div className="address-member-picker">
<div className="address-contact-toolbar address-member-search"> <div className="address-contact-toolbar address-member-search">
<input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus /> <input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus />
@@ -2184,12 +2170,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
disabled={saving || targetOptions.length === 0}> disabled={saving || targetOptions.length === 0}>
{targetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)} {targetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
</select> </select>
<ReasonedButton <Button
type="button" type="button"
onClick={() => selectedList && void addContactToAddressList(selectedList, contact, selectedTarget)} onClick={() => selectedList && void addContactToAddressList(selectedList, contact, selectedTarget)}
disabledReason={addContactToListReason(selectedList, contact, selectedTarget?.key ?? "")}> disabledReason={addContactToListReason(selectedList, contact, selectedTarget?.key ?? "")}>
<Plus size={15} /> Add <Plus size={15} /> Add
</ReasonedButton> </Button>
</div> </div>
); );
}) })
@@ -2207,8 +2193,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton> <Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<ReasonedButton type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</ReasonedButton> <Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>
</> </>
}> }>
<form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}> <form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
@@ -2234,9 +2220,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</ReasonedButton> <Button type="button" onClick={() => setCardDavOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<ReasonedButton type="button" onClick={() => void discoverCardDavSources()} disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV URL before discovery."])}><Search size={16} /> Discover</ReasonedButton> <Button type="button" onClick={() => void discoverCardDavSources()} disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV URL before discovery."])}><Search size={16} /> Discover</Button>
<ReasonedButton type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</ReasonedButton> <Button type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</Button>
</> </>
}> }>
<form id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}> <form id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
@@ -2302,9 +2288,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<ReasonedButton type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</ReasonedButton> <Button type="button" onClick={() => setSyncInspector(null)} disabledReason={dialogCancelReason}>Close</Button>
<ReasonedButton type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</ReasonedButton> <Button type="button" onClick={() => syncInspector && void previewSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [saving, savingReason])}>Preview</Button>
<ReasonedButton type="button" variant="primary" onClick={() => syncInspector && void runSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [!canWriteSync, "You need permission to run address sync."], [saving, savingReason])}>Run sync</ReasonedButton> <Button type="button" variant="primary" onClick={() => syncInspector && void runSelectedSync(syncInspector.source)} disabledReason={disabledReason([!syncInspector, "No sync source selected."], [!canWriteSync, "You need permission to run address sync."], [saving, savingReason])}>Run sync</Button>
</> </>
}> }>
{syncInspector && {syncInspector &&
@@ -2322,19 +2308,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<p className="muted">Last attempt: {formatDateTime(syncInspector.source.last_attempted_at)} · Last success: {formatDateTime(syncInspector.source.last_success_at)}</p> <p className="muted">Last attempt: {formatDateTime(syncInspector.source.last_attempted_at)} · Last success: {formatDateTime(syncInspector.source.last_success_at)}</p>
{syncInspector.source.last_error && <DismissibleAlert tone="danger" resetKey={syncInspector.source.last_error}>{syncInspector.source.last_error}</DismissibleAlert>} {syncInspector.source.last_error && <DismissibleAlert tone="danger" resetKey={syncInspector.source.last_error}>{syncInspector.source.last_error}</DismissibleAlert>}
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<ReasonedButton <Button
type="button" type="button"
onClick={() => void toggleSyncSourceEnabled(syncInspector.source)} onClick={() => void toggleSyncSourceEnabled(syncInspector.source)}
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}> disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
{syncInspector.source.enabled ? "Disable" : "Enable"} {syncInspector.source.enabled ? "Disable" : "Enable"}
</ReasonedButton> </Button>
<ReasonedButton <Button
type="button" type="button"
variant="danger" variant="danger"
onClick={() => setConfirmState({ kind: "sync-source", source: syncInspector.source })} onClick={() => setConfirmState({ kind: "sync-source", source: syncInspector.source })}
disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}> disabledReason={disabledReason([!canWriteSync, "You need permission to manage address sync."], [saving, savingReason])}>
Disconnect Disconnect
</ReasonedButton> </Button>
</div> </div>
</div> </div>
@@ -2361,7 +2347,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{syncConflicts.map((conflict) => ( {syncConflicts.map((conflict) => (
<div className="address-sync-record-row" key={conflict.id}> <div className="address-sync-record-row" key={conflict.id}>
<span><strong>{conflict.field_path}</strong><small>{conflict.resource_href || conflict.remote_uid || conflict.contact_id}</small></span> <span><strong>{conflict.field_path}</strong><small>{conflict.resource_href || conflict.remote_uid || conflict.contact_id}</small></span>
<ReasonedButton type="button" onClick={() => openConflictReview(conflict)} disabledReason={disabledReason([saving, savingReason])}>Review</ReasonedButton> <Button type="button" onClick={() => openConflictReview(conflict)} disabledReason={disabledReason([saving, savingReason])}>Review</Button>
</div> </div>
))} ))}
</div> </div>
@@ -2405,20 +2391,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
className="address-sync-dialog" className="address-sync-dialog"
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={conflictDialog && <> footer={conflictDialog && <>
<ReasonedButton type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</ReasonedButton> <Button type="button" onClick={() => { setConflictDialog(null); setConflictMergeChoices({}); }} disabledReason={dialogCancelReason}>Close</Button>
<ReasonedButton <Button
type="button" type="button"
onClick={() => void resolveConflictWith(conflictDialog, "ignored", "ignored")} onClick={() => void resolveConflictWith(conflictDialog, "ignored", "ignored")}
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}> disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
Ignore Ignore
</ReasonedButton> </Button>
<ReasonedButton <Button
type="button" type="button"
onClick={() => void resolveConflictWith(conflictDialog, "keep_local")} onClick={() => void resolveConflictWith(conflictDialog, "keep_local")}
disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}> disabledReason={disabledReason([!canWriteSync, "You need permission to resolve sync conflicts."], [saving, savingReason])}>
Keep local Keep local
</ReasonedButton> </Button>
<ReasonedButton <Button
type="button" type="button"
onClick={() => void applyMergedConflict(conflictDialog)} onClick={() => void applyMergedConflict(conflictDialog)}
disabledReason={disabledReason( disabledReason={disabledReason(
@@ -2427,8 +2413,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason] [saving, savingReason]
)}> )}>
Apply merged Apply merged
</ReasonedButton> </Button>
<ReasonedButton <Button
type="button" type="button"
variant="primary" variant="primary"
onClick={() => void resolveConflictWith(conflictDialog, "use_remote")} onClick={() => void resolveConflictWith(conflictDialog, "use_remote")}
@@ -2438,7 +2424,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason] [saving, savingReason]
)}> )}>
Use remote Use remote
</ReasonedButton> </Button>
</>}> </>}>
{conflictDialog && {conflictDialog &&
<div className="address-conflict-review"> <div className="address-conflict-review">