diff --git a/website/src/app/contacts/contact-edit/ContactEditComponent.css b/website/src/app/contacts/contact-edit/ContactEditComponent.css new file mode 100644 index 0000000..adc0989 --- /dev/null +++ b/website/src/app/contacts/contact-edit/ContactEditComponent.css @@ -0,0 +1,38 @@ +/* + * Styles for the React ContactEditComponent. + * Mirrors the Angular contact-edit.component.css styles. + */ + +.messages { + text-align: center; +} + +.back-button { + cursor: pointer; + margin-top: 10px; + transform: rotate(180deg); +} + +.back-button-icon { + color: white; +} + +.avatar { + margin-top: 15px; +} + +input { + width: 300px; +} + +i { + cursor: pointer; +} + +.left-padding { + padding-left: 35px; +} + +.update-contact-button { + padding-left: 25px; +} diff --git a/website/src/app/contacts/contact-edit/ContactEditComponent.tsx b/website/src/app/contacts/contact-edit/ContactEditComponent.tsx new file mode 100644 index 0000000..13cb851 --- /dev/null +++ b/website/src/app/contacts/contact-edit/ContactEditComponent.tsx @@ -0,0 +1,247 @@ +import { useState, useEffect, useCallback } from 'react'; + +import { constants } from './contact-edit.constants'; +// TODO: countryDialingCodes is shared with the Angular app; consider extracting to a shared util +import { countryDialingCodes } from '../shared/phone-number/country-dialing-codes'; + +/** + * Contact model matching the Angular Contact interface + * from shared/models/contact.model.ts + */ +export interface Contact { + id: number; + name?: string; + email?: string; + number?: string; + country?: string; + favorite?: boolean; +} + +/** + * Service interface matching the Angular ContactService API. + * Consumers must provide an implementation via props. + */ +export interface ContactServiceLike { + getContact(id: number): Promise; + save(contact: Contact): Promise; +} + +/** + * Callback invoked when a validation modal should be shown. + * `type` indicates which modal: 'email' or 'phone'. + */ +export type OnShowModal = (type: 'email' | 'phone') => void; + +/** + * Callback invoked to display a snack-bar / toast notification. + */ +export type OnShowSnackBar = (message: string, duration: number) => void; + +export interface ContactEditComponentProps { + /** Route param: the contact id to load */ + contactId: number; + /** Service used to load and save contacts */ + contactService: ContactServiceLike; + /** Called after a successful update to navigate away (replaces Angular Router.navigate) */ + onNavigateHome: () => void; + /** Called when an invalid-email or invalid-phone-number modal should be opened */ + onShowModal?: OnShowModal; + /** Called to display a snack-bar notification */ + onShowSnackBar?: OnShowSnackBar; +} + +// --------------------------------------------------------------------------- +// Validation helpers (1:1 port of the Angular private methods) +// --------------------------------------------------------------------------- + +function isEmailValid(email: string): boolean { + return email === '' || (email !== '' && email.includes('@') && email.includes('.')); +} + +function isPhoneNumberValid(phoneNumber: string): boolean { + return phoneNumber === '' || (phoneNumber !== '' && phoneNumber.length === 10 && /^\d+$/.test(phoneNumber)); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export const ContactEditComponent: React.FC = ({ + contactId, + contactService, + onNavigateHome, + onShowModal, + onShowSnackBar, +}) => { + const [isLoading, setIsLoading] = useState(true); + const [contact, setContact] = useState(null); + + const countryDialingCodeKeys: string[] = Object.keys(countryDialingCodes); + + // --- ngOnInit equivalent: load contact on mount / when contactId changes --- + useEffect(() => { + let cancelled = false; + + setIsLoading(true); + contactService.getContact(contactId).then((loadedContact) => { + if (!cancelled) { + setIsLoading(false); + setContact(loadedContact); + } + }); + + return () => { + cancelled = true; + }; + }, [contactId, contactService]); + + // --- ngOnDestroy equivalent --- + // The Angular component closed its modal ref on destroy. + // In React the parent controls the modal lifecycle via onShowModal, + // so cleanup is handled externally. + // TODO: If modal management moves into this component, add cleanup in a useEffect return. + + // --- Event handlers --- + + const handleFieldChange = useCallback( + (field: keyof Contact, value: string) => { + setContact((prev) => (prev ? { ...prev, [field]: value } : prev)); + }, + [], + ); + + const saveContact = useCallback( + (currentContact: Contact) => { + const updated: Contact = { ...currentContact, favorite: !currentContact.favorite }; + setContact(updated); + contactService.save(updated); + }, + [contactService], + ); + + const updateContact = useCallback( + (currentContact: Contact) => { + // Validate email + if (!isEmailValid(currentContact.email ?? '')) { + onShowModal?.('email'); + return; + } + + // Validate phone number + if (!isPhoneNumberValid(currentContact.number ?? '')) { + onShowModal?.('phone'); + return; + } + + // Show snack bar + onShowSnackBar?.('Contact updated', 2000); + + contactService.save(currentContact).then(() => { + onNavigateHome(); + }); + }, + [contactService, onNavigateHome, onShowModal, onShowSnackBar], + ); + + // --- Render --- + + if (isLoading) { + return ( +
+
{constants.LOADING_CONTACT_MESSAGE}
+ {/* TODO: replace with a React progress bar component */} +
+
+ ); + } + + if (!contact) { + return ( +
+
{constants.NO_CONTACT_FOUND_MESSAGE}
+ {/* TODO: replace with React Router */} + + + +
+ ); + } + + return ( +
+
+
+
+ {/* TODO: Replace with a proper icon component (e.g. Material Icons React wrapper) */} + mood +
+ saveContact(contact)} + /> +
+ handleFieldChange('name', e.target.value)} + className="contact-name" + /> +
+
+
+
+ handleFieldChange('email', e.target.value)} + /> +
+
+
+
+ handleFieldChange('number', e.target.value)} + /> +
+
+
+
+ +
+
+
+
+ +
+
+
+ + {/* TODO: replace with React Router */} + + + +
+ ); +};