From 30aa5c4250d829d4a8b8d1d8fbc792c459a64b0f Mon Sep 17 00:00:00 2001 From: AlexisG Date: Fri, 15 Nov 2024 11:15:11 +0100 Subject: [PATCH 1/3] feat: Update relations choices --- .../ContactCard/ContactForm/fieldsConfig.js | 12 ++++++++---- src/locales/en.json | 5 +++-- src/locales/fr.json | 13 +++++++------ 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/components/ContactCard/ContactForm/fieldsConfig.js b/src/components/ContactCard/ContactForm/fieldsConfig.js index be4f9a10c..5c454031f 100644 --- a/src/components/ContactCard/ContactForm/fieldsConfig.js +++ b/src/components/ContactCard/ContactForm/fieldsConfig.js @@ -285,13 +285,17 @@ export const fields = [ value: '{"type":"coWorker"}', label: 'label.relationship.coWorker' }, - { - value: '{"type":"agent"}', - label: 'label.relationship.agent' - }, { value: '{"type":"acquaintance"}', label: 'label.relationship.acquaintance' + }, + { + value: '{"type":"helper"}', + label: 'label.relationship.helper' + }, + { + value: '{"type":"recipient"}', + label: 'label.relationship.recipient' } ] }, diff --git a/src/locales/en.json b/src/locales/en.json index 2646d315b..db6be6d87 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -98,8 +98,9 @@ "friend": "Friend", "colleague": "Colleague", "coWorker": "Co-worker", - "agent": "Agent", - "acquaintance": "Acquaintance" + "acquaintance": "Acquaintance", + "helper": "Helper", + "recipient": "Recipient" } }, "gender": { diff --git a/src/locales/fr.json b/src/locales/fr.json index 4a9746aeb..e9aa10e46 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -93,13 +93,14 @@ "parent": "Parent", "child": "Enfant", "sibling": "Frère/Soeur", - "spouse": "Conjoint(e)", + "spouse": "Conjoint", "coResident": "Colocataire", - "friend": "Ami(e)", - "colleague": "Collègue", - "coWorker": "Associé(e)", - "agent": "Responsable", - "acquaintance": "Assistance" + "friend": "Ami", + "colleague": "Collègue/Collaborateur", + "coWorker": "Associé/Partenaire", + "acquaintance": "Connaissance", + "helper": "Assistant/Aidant", + "recipient": "Bénéficiaire/Aidé" } }, "gender": { From bea68e3516c9c7dbbc5892748869b79979d9ce91 Mon Sep 17 00:00:00 2001 From: AlexisG Date: Fri, 15 Nov 2024 16:07:30 +0100 Subject: [PATCH 2/3] feat: Synchronize relation between contacts Following on from the work started on this PR #985. The aim is to synchronize relationship changes between the contacts concerned. --- src/components/Modals/ContactFormModal.jsx | 2 +- .../Modals/ContactFormModal.spec.jsx | 10 +- src/connections/allContacts.js | 36 ++- src/connections/relatedRelationships.js | 263 ++++++++++++++++++ src/connections/relatedRelationships.spec.js | 205 ++++++++++++++ src/types.js | 18 ++ 6 files changed, 526 insertions(+), 8 deletions(-) create mode 100644 src/connections/relatedRelationships.js create mode 100644 src/connections/relatedRelationships.spec.js diff --git a/src/components/Modals/ContactFormModal.jsx b/src/components/Modals/ContactFormModal.jsx index 0bb53108d..90f0367ee 100644 --- a/src/components/Modals/ContactFormModal.jsx +++ b/src/components/Modals/ContactFormModal.jsx @@ -67,7 +67,7 @@ const ContactFormModal = () => { try { await createOrUpdateContact({ client, - isUpdated: !!currentContact, + oldContact: currentContact, formData, selectedGroup }) diff --git a/src/components/Modals/ContactFormModal.spec.jsx b/src/components/Modals/ContactFormModal.spec.jsx index 6338613c3..3ad130652 100644 --- a/src/components/Modals/ContactFormModal.spec.jsx +++ b/src/components/Modals/ContactFormModal.spec.jsx @@ -141,7 +141,7 @@ describe('ContactFormModal component', () => { expect(createOrUpdateContact).toBeCalledWith({ client: expect.anything(), - isUpdated: false, + oldContact: undefined, formData: expected, selectedGroup: expect.anything() }) @@ -224,7 +224,13 @@ describe('ContactFormModal component', () => { expect(createOrUpdateContact).toHaveBeenCalledWith({ client: expect.anything(), - isUpdated: true, + oldContact: { + _id: 'ID', + name: { + familyName: 'John', + givenName: 'Doe' + } + }, formData: expected, selectedGroup: expect.anything() }) diff --git a/src/connections/allContacts.js b/src/connections/allContacts.js index 921faa402..8df785924 100644 --- a/src/connections/allContacts.js +++ b/src/connections/allContacts.js @@ -4,6 +4,10 @@ import uniqWith from 'lodash/uniqWith' import minilog from 'cozy-minilog' +import { + getRelatedRelationshipsToUpdate, + updateRelatedRelationships +} from './relatedRelationships' import { addGroupToContact } from '../helpers/contacts' import { DOCTYPE_CONTACTS } from '../helpers/doctypes' import { hasSelectedGroup } from '../helpers/groups' @@ -77,14 +81,14 @@ export const deleteTrashedContacts = async client => { /** * @param {Object} params * @param {import('cozy-client/types/CozyClient').default} params.client - CozyClient - * @param {boolean} params.isUpdated - Is the contact updated + * @param {import('cozy-client/types/types').ContactDocument} params.oldContact - Is the contact updated * @param {Object} params.formData - Contact data * @param {Object} params.selectedGroup - Selected group * @returns {Promise} - Contact */ export const createOrUpdateContact = async ({ client, - isUpdated, + oldContact, formData, selectedGroup }) => { @@ -93,9 +97,31 @@ export const createOrUpdateContact = async ({ if (hasSelectedGroup(selectedGroup)) { updatedContact = addGroupToContact(updatedContact, selectedGroup) } - return isUpdated - ? client.save(updatedContact) - : client.create(DOCTYPE_CONTACTS, updatedContact) + + const { data: originalContact } = oldContact + ? await client.save(updatedContact) + : await client.create(DOCTYPE_CONTACTS, updatedContact) + + const relatedRelationships = getRelatedRelationshipsToUpdate( + oldContact, + updatedContact + ) + + if (relatedRelationships) { + try { + const relatedContactsToSaved = await updateRelatedRelationships({ + client, + relatedRelationships, + originalContactId: originalContact._id + }) + + client.saveAll(relatedContactsToSaved) + } catch (error) { + log.error('Error updating related contacts', error) + } + } + + return originalContact } /** diff --git a/src/connections/relatedRelationships.js b/src/connections/relatedRelationships.js new file mode 100644 index 000000000..b87c22f98 --- /dev/null +++ b/src/connections/relatedRelationships.js @@ -0,0 +1,263 @@ +import difference from 'lodash/difference' +import isEqual from 'lodash/isEqual' + +import { + getHasManyItems, + setHasManyItem, + removeHasManyItem +} from 'cozy-client/dist/associations/HasMany' +import minilog from 'cozy-minilog' + +import { DOCTYPE_CONTACTS } from '../helpers/doctypes' +import { buildContactsQueryById } from '../queries/queries' + +const log = minilog('connections/relatedRelationships') + +const RELATED_RELATION_TYPES_MAPPING = { + parent: 'child', + child: 'parent', + sibling: 'sibling', + spouse: 'spouse', + coResident: 'coResident', + friend: 'friend', + colleague: 'colleague', + coWorker: 'coWorker', + acquaintance: 'acquaintance', + helper: 'recipient', + recipient: 'helper', + related: 'related' // default value +} + +/** + * @param {string[]} relationTypes - Relation types + */ +const getRelationTypesMapping = relationTypes => { + return relationTypes.map(relationType => { + return RELATED_RELATION_TYPES_MAPPING[relationType] + }) +} + +/** + * @param {import('cozy-client/types/types').IOCozyContact} oldContact - Old contact + * @param {import('cozy-client/types/types').IOCozyContact} updatedContact - Updated contact + * @returns {boolean} - True if related relationships are the same + */ +export const isSameRelatedRelationships = (oldContact, updatedContact) => { + return isEqual( + getHasManyItems(oldContact, 'related'), + getHasManyItems(updatedContact, 'related') + ) +} + +/** + * @param {import('cozy-client/types/types').IOCozyContact} oldContact - Old contact + * @param {import('cozy-client/types/types').IOCozyContact} updatedContact - Updated contact + * @returns {import('../types').RelatedRelationshipsToUpdate[]|null} - Related relationships to update + */ +export const getRelatedRelationshipsToUpdate = (oldContact, updatedContact) => { + const isSameRelatedContactRelationships = isSameRelatedRelationships( + oldContact, + updatedContact + ) + if (isSameRelatedContactRelationships) { + return null + } + + const newRelatedRel = getHasManyItems(updatedContact, 'related') + const oldRelatedRel = getHasManyItems(oldContact, 'related') + const relatedRelToUpdate = [] + + // Find updated or deleted related contacts + oldRelatedRel.forEach(oldRel => { + const newRel = newRelatedRel.find(newRel => newRel._id === oldRel._id) + + if (!newRel) { + relatedRelToUpdate.push({ + type: 'DELETE', + relation: oldRel + }) + } else { + const oldRelationTypes = oldRel.metadata.relationTypes + const newRelationTypes = newRel.metadata.relationTypes + + const addedRelationTypes = difference(newRelationTypes, oldRelationTypes) + const removedRelationTypes = difference( + oldRelationTypes, + newRelationTypes + ) + + if (addedRelationTypes.length > 0 || removedRelationTypes.length > 0) { + relatedRelToUpdate.push({ + type: 'UPDATE', + relation: newRel + }) + } + } + }) + + // Find new related contacts + newRelatedRel.forEach(newRel => { + const oldRel = oldRelatedRel.find(oldRel => oldRel._id === newRel._id) + if (!oldRel) { + relatedRelToUpdate.push({ + type: 'CREATE', + relation: newRel + }) + } + }) + + if (relatedRelToUpdate.length === 0) { + return null + } + + return relatedRelToUpdate +} + +/** + * Make the new related object converted for the related contact + * @param {import('cozy-client/types/types').IOCozyContact} originalContactRelation - Original contact relation + * @param {string} originalContactId - Original contact id + */ +export const makeRelationMapping = ( + originalContactRelation, + originalContactId +) => { + const relationTypesMapping = getRelationTypesMapping( + originalContactRelation.metadata.relationTypes + ) + return { + _id: originalContactId, + _type: DOCTYPE_CONTACTS, + metadata: { + relationTypes: relationTypesMapping + } + } +} + +/** + * @param {Object} params + * @param {import('cozy-client/types/CozyClient').default} params.client - CozyClient + * @param {import('../types').RelatedRelationships} params.relation - Related relationships to update + * @param {string} params.originalContactId - Original contact id + * @returns {Promise} - Related contacts + */ +export const createRelatedContact = async ({ + client, + relation, + originalContactId +}) => { + try { + const contactsQueryById = buildContactsQueryById(relation._id) + const { data: relatedContact } = await client.query( + contactsQueryById.definition() + ) + const relationConverted = makeRelationMapping(relation, originalContactId) + const updatedRelatedContact = setHasManyItem( + relatedContact, + 'related', + originalContactId, + relationConverted + ) + + return updatedRelatedContact + } catch (error) { + log.error('Error creating related contact', error) + } +} + +/** + * @param {Object} params + * @param {import('cozy-client/types/CozyClient').default} params.client - CozyClient + * @param {import('../types').RelatedRelationships} params.relation - Related relationships to update + * @param {string} params.originalContactId - Original contact id + * @returns {Promise} - Related contacts + */ +export const updateRelatedContact = async ({ + client, + relation, + originalContactId +}) => { + try { + const contactsQueryById = buildContactsQueryById(relation._id) + const { data: relatedContact } = await client.query( + contactsQueryById.definition() + ) + const relationConverted = makeRelationMapping(relation, originalContactId) + const updatedRelatedContact = setHasManyItem( + relatedContact, + 'related', + originalContactId, + relationConverted + ) + + return updatedRelatedContact + } catch (error) { + log.error('Error updating related contact', error) + } +} + +/** + * @param {Object} params + * @param {import('cozy-client/types/CozyClient').default} params.client - CozyClient + * @param {import('../types').RelatedRelationships} params.relation - Related relationships to update + * @param {string} params.originalContactId - Original contact id + * @returns {Promise} - Related contacts + */ +export const deleteRelatedContact = async ({ + client, + relation, + originalContactId +}) => { + try { + const contactsQueryById = buildContactsQueryById(relation._id) + const { data: relatedContact } = await client.query( + contactsQueryById.definition() + ) + const updatedRelatedContact = removeHasManyItem( + relatedContact, + 'related', + originalContactId + ) + + return updatedRelatedContact + } catch (error) { + log.error('Error deleting related contact', error) + } +} + +/** + * @param {Object} params + * @param {import('cozy-client/types/CozyClient').default} params.client - CozyClient + * @param {import('../types').RelatedRelationshipsToUpdate[]} params.relatedRelationships - Related relationships to update + * @param {import('cozy-client/types/types').IOCozyContact} params.originalContact - Original contact + */ +export const updateRelatedRelationships = async ({ + client, + relatedRelationships, + originalContactId +}) => { + return Promise.all( + relatedRelationships.map(relatedRel => { + switch (relatedRel.type) { + case 'CREATE': + return createRelatedContact({ + client, + relation: relatedRel.relation, + originalContactId + }) + case 'UPDATE': + return updateRelatedContact({ + client, + relation: relatedRel.relation, + originalContactId + }) + case 'DELETE': + return deleteRelatedContact({ + client, + relation: relatedRel.relation, + originalContactId + }) + } + }) + ) +} diff --git a/src/connections/relatedRelationships.spec.js b/src/connections/relatedRelationships.spec.js new file mode 100644 index 000000000..4f9511237 --- /dev/null +++ b/src/connections/relatedRelationships.spec.js @@ -0,0 +1,205 @@ +import { + getRelatedRelationshipsToUpdate, + makeRelationMapping, + isSameRelatedRelationships +} from './relatedRelationships' +import { DOCTYPE_CONTACTS } from '../helpers/doctypes' + +describe('relatedRelationships', () => { + describe('getRelatedRelationshipsToUpdate', () => { + it('should return "null" if oldContact and updatedContact don\'t have related relationships', () => { + const oldContact = { + _id: 'contact0', + name: 'Alice' + } + const updatedContact = { + _id: 'contact0', + name: 'Alice' + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toBeNull() + }) + + it('should return "null" if the related relationships of oldContact and updatedContact have not changed.', () => { + const oldContact = { + _id: 'contact0', + name: 'Alice', + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['spouse'] } }] + } + } + } + const updatedContact = { + _id: 'contact0', + name: 'Alice', + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['spouse'] } }] + } + } + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toBeNull() + }) + + it('should return new related relationships to update', () => { + const oldContact = { + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['friend'] } }] + } + } + } + const updatedContact = { + relationships: { + related: { + data: [ + { _id: 'related0', metadata: { relationTypes: ['friend'] } }, + { _id: 'related1', metadata: { relationTypes: ['spouse'] } } + ] + } + } + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toEqual([ + { + relation: { + _id: 'related1', + metadata: { relationTypes: ['spouse'] } + }, + type: 'CREATE' + } + ]) + }) + + it('should return updated related relationships to update', () => { + const oldContact = { + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['friend'] } }] + } + } + } + const updatedContact = { + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['spouse'] } }] + } + } + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toEqual([ + { + relation: { + _id: 'related0', + metadata: { relationTypes: ['spouse'] } + }, + type: 'UPDATE' + } + ]) + }) + + it('should return removed related relationships to update', () => { + const oldContact = { + relationships: { + related: { + data: [ + { _id: 'related0', metadata: { relationTypes: ['friend'] } }, + { _id: 'related1', metadata: { relationTypes: ['spouse'] } } + ] + } + } + } + const updatedContact = { + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['friend'] } }] + } + } + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toEqual([ + { + relation: { + _id: 'related1', + metadata: { relationTypes: ['spouse'] } + }, + type: 'DELETE' + } + ]) + }) + }) + describe('isSameRelatedRelationships', () => { + it('should return true if related relationships are the same', () => { + const oldContact = { + _id: 'contact0', + name: 'Alice', + relationships: { + related: { + data: [{ _id: 'related0' }] + } + } + } + const updatedContact = { + _id: 'contact0', + name: 'Bob', + relationships: { + related: { + data: [{ _id: 'related0' }] + } + } + } + + expect(isSameRelatedRelationships(oldContact, updatedContact)).toBe(true) + }) + + it('should return false if related relationships are different', () => { + const oldContact = { + relationships: { + related: { + data: [{ _id: 'related0' }] + } + } + } + const updatedContact = { + relationships: { + related: { + data: [{ _id: 'related1' }] + } + } + } + + expect(isSameRelatedRelationships(oldContact, updatedContact)).toBe(false) + }) + }) + describe('makeRelationMapping', () => { + it('should return a mapping of relation types', () => { + const originalContactId = 'relatedO' + const originalContactRelation = { + _id: 'related1', + metadata: { relationTypes: ['parent'] } + } + + expect( + makeRelationMapping(originalContactRelation, originalContactId) + ).toEqual({ + _id: originalContactId, + _type: DOCTYPE_CONTACTS, + metadata: { + relationTypes: ['child'] + } + }) + }) + }) +}) diff --git a/src/types.js b/src/types.js index f8fa7d600..33770dedb 100644 --- a/src/types.js +++ b/src/types.js @@ -46,4 +46,22 @@ * @property {string} relatedContactLabel - Object with the type of the related contact stringified (e.g. '{\"type\":\"spouse\"}') */ +/** + * @typedef {Object} RelatedRelationshipsMetadata + * @property {string[]} relationTypes - The relation types of the related contact + */ + +/** + * @typedef {Object} RelatedRelationships + * @property {string} _id - The id of the related contact + * @property {string} _type - The doctype of the related contact + * @property {relatedRelationshipsMetadata} metadata - The metadata of the related contact + */ + +/** + * @typedef {Object} RelatedRelationshipsToUpdate + * @property {'CREATE'|'UPDATE'|'DELETE'} type - The action to update the related relationships + * @property {RelatedRelationships} relation - The doctype of the related contact + */ + export default {} From eaac9c00dd784f8c29ff8fa72e2b2de8342769c0 Mon Sep 17 00:00:00 2001 From: AlexisG Date: Sat, 16 Nov 2024 10:48:15 +0100 Subject: [PATCH 3/3] fix: HasMany helpers doesn't work when remove deep values See https://github.com/cozy/cozy-client/issues/1560 --- src/connections/relatedRelationships.js | 33 +++++++-- src/connections/relatedRelationships.spec.js | 73 ++++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/connections/relatedRelationships.js b/src/connections/relatedRelationships.js index b87c22f98..0187c8a96 100644 --- a/src/connections/relatedRelationships.js +++ b/src/connections/relatedRelationships.js @@ -165,6 +165,31 @@ export const createRelatedContact = async ({ } } +/** + * setHasManyItem/updateHasManyItem do not work when remove one or many metadata.relationTypes + * @param {Object} params + * @param {import('cozy-client/types/types').IOCozyContact} params.relatedContact - Related contact + * @param {string} params.originalContactId - Original contact id + * @param {import('../types').RelatedRelationships} params.relation - Relation to update + */ +const updateRelatedRelationshipsWithoutMerge = ({ + relatedContact, + originalContactId, + relation +}) => { + const updatedRelatedContact = structuredClone(relatedContact) + const relatedIndex = + updatedRelatedContact.relationships.related.data.findIndex( + el => el._id === originalContactId + ) + + if (relatedIndex >= 0) { + updatedRelatedContact.relationships.related.data[relatedIndex] = relation + } + + return updatedRelatedContact +} + /** * @param {Object} params * @param {import('cozy-client/types/CozyClient').default} params.client - CozyClient @@ -183,12 +208,12 @@ export const updateRelatedContact = async ({ contactsQueryById.definition() ) const relationConverted = makeRelationMapping(relation, originalContactId) - const updatedRelatedContact = setHasManyItem( + + const updatedRelatedContact = updateRelatedRelationshipsWithoutMerge({ relatedContact, - 'related', originalContactId, - relationConverted - ) + relation: relationConverted + }) return updatedRelatedContact } catch (error) { diff --git a/src/connections/relatedRelationships.spec.js b/src/connections/relatedRelationships.spec.js index 4f9511237..52d2b2504 100644 --- a/src/connections/relatedRelationships.spec.js +++ b/src/connections/relatedRelationships.spec.js @@ -108,6 +108,79 @@ describe('relatedRelationships', () => { ]) }) + it('should return updated related relationships when adding relationTypes', () => { + const oldContact = { + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['friend'] } }] + } + } + } + const updatedContact = { + relationships: { + related: { + data: [ + { + _id: 'related0', + metadata: { relationTypes: ['friend', 'spouse'] } + } + ] + } + } + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toEqual([ + { + relation: { + _id: 'related0', + metadata: { relationTypes: ['friend', 'spouse'] } + }, + type: 'UPDATE' + } + ]) + }) + + it('should return updated related relationships when deleting relationTypes', () => { + const oldContact = { + relationships: { + related: { + data: [ + { + _id: 'related0', + metadata: { relationTypes: ['friend', 'spouse'] } + } + ] + } + } + } + const updatedContact = { + relationships: { + related: { + data: [ + { + _id: 'related0', + metadata: { relationTypes: ['friend'] } + } + ] + } + } + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toEqual([ + { + relation: { + _id: 'related0', + metadata: { relationTypes: ['friend'] } + }, + type: 'UPDATE' + } + ]) + }) + it('should return removed related relationships to update', () => { const oldContact = { relationships: {