From bf7569f8b8bae76bc6123bc0965f37e7c835b486 Mon Sep 17 00:00:00 2001 From: cballevre Date: Mon, 30 Oct 2023 14:56:36 +0100 Subject: [PATCH] fix: Realtime conflict into Profile page The document sent by cozy-stack in real time is the CouchDB document. Unlike a call to the JSON Api, the document does not contain the attribute field. When a change was made, it generated a conflict error. I modified the RealTimeQueries so that I could modify the real-time object before integrating it into the cozy-client. This makes it possible to have the same version everywhere and avoid conflicts. See also: https://github.com/cozy/cozy-client/issues/1412 --- src/components/App.jsx | 3 +- src/components/SettingsRealTimeQueries.jsx | 146 ++++++++++++++++++ .../SettingsRealTimeQueries.spec.jsx | 76 +++++++++ 3 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/components/SettingsRealTimeQueries.jsx create mode 100644 src/components/SettingsRealTimeQueries.spec.jsx diff --git a/src/components/App.jsx b/src/components/App.jsx index 6a326ab5..3db629aa 100755 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -31,6 +31,7 @@ import { initFlags } from 'lib/flags' import { routes } from 'constants/routes' import ChangeEmail from 'components/Email/ChangeEmail' import { Subscription } from 'components/Subscription/Subscription' +import SettingsRealTimeQueries from 'components/SettingsRealTimeQueries' initFlags() @@ -55,7 +56,7 @@ export const App = () => { {isBigView && } - +
{isSmallView && } />} diff --git a/src/components/SettingsRealTimeQueries.jsx b/src/components/SettingsRealTimeQueries.jsx new file mode 100644 index 00000000..caf1f9a7 --- /dev/null +++ b/src/components/SettingsRealTimeQueries.jsx @@ -0,0 +1,146 @@ +import { memo, useEffect } from 'react' +import { useClient, Mutations } from 'cozy-client' +import { receiveMutationResult } from 'cozy-client/dist/store' + +/** + * Normalizes an object representing a CouchDB document + * + * Ensures existence of `_type` + * + * @public + * @param {CouchDBDocument} couchDBDoc - object representing the document + * @returns {CozyClientDocument} full normalized document + */ +const normalizeDoc = (couchDBDoc, doctype) => { + return { + id: couchDBDoc._id, + _type: doctype, + ...couchDBDoc + } +} + +/** + * DispatchChange + * + * @param {CozyClient} client CozyClient instane + * @param {Doctype} doctype Doctype of the document to update + * @param {CouchDBDocument} couchDBDoc Document to update + * @param {Mutation} mutationDefinitionCreator Mutation to apply + */ +const dispatchChange = ( + client, + doctype, + couchDBDoc, + mutationDefinitionCreator +) => { + const data = normalizeDoc(couchDBDoc, doctype) + const response = { + data + } + + const options = {} + client.dispatch( + receiveMutationResult( + client.generateRandomId(), + response, + options, + mutationDefinitionCreator(data) + ) + ) +} + +/** + * The document real time comes without attributes, there are only at the root. + * That's why we need to merge the attributes from the document in the store. + * @param {CozyClient} client - CozyClient instane + * @param {CouchDBDocument} docFromRealTime - object representing the document from real time + * @returns {object} merged document + */ +export const computeDocumentFromRealTime = (client, docFromRealTime) => { + const { _id, _type, _rev, ...attributes } = docFromRealTime + const docFromState = client.getDocumentFromState('io.cozy.settings', _id) + + if (docFromState) { + return { + ...docFromState, + _id, + _type, + _rev, + ...attributes, + attributes: { + ...docFromState?.attributes, + ...attributes + }, + meta: { + rev: _rev + } + } + } + + return { + ...docFromRealTime, + attributes: { + ...attributes + } + } +} + +/** + * Component that subscribes to io.cozy.settings document changes and keep the + * internal store updated. This is a copy of RealTimeQueries from cozy-client + * with a tweak to merge the changes with the existing document from the store. + * You can have more detail on the problematic we are solving here: + * https://github.com/cozy/cozy-client/issues/1412 + * + * @param {object} options - Options + * @param {Doctype} options.doctype - The doctype to watch + * @returns {null} The component does not display anything. + */ +const SettingsRealTimeQueries = ({ doctype = 'io.cozy.settings' }) => { + const client = useClient() + + useEffect(() => { + const realtime = client.plugins.realtime + + if (!realtime) { + throw new Error( + 'You must include the realtime plugin to use RealTimeQueries' + ) + } + + const dispatchCreate = couchDBDoc => { + const doc = computeDocumentFromRealTime(client, couchDBDoc) + dispatchChange(client, doctype, doc, Mutations.createDocument) + } + const dispatchUpdate = couchDBDoc => { + const doc = computeDocumentFromRealTime(client, couchDBDoc) + dispatchChange(client, doctype, doc, Mutations.updateDocument) + } + const dispatchDelete = couchDBDoc => { + const doc = computeDocumentFromRealTime(client, couchDBDoc) + dispatchChange( + client, + doctype, + { ...doc, _deleted: true }, + Mutations.deleteDocument + ) + } + + const subscribe = async () => { + await realtime.subscribe('created', doctype, dispatchCreate) + await realtime.subscribe('updated', doctype, dispatchUpdate) + await realtime.subscribe('deleted', doctype, dispatchDelete) + } + subscribe() + + return () => { + realtime.unsubscribe('created', doctype, dispatchCreate) + realtime.unsubscribe('updated', doctype, dispatchUpdate) + realtime.unsubscribe('deleted', doctype, dispatchDelete) + } + }, [client, doctype]) + + return null +} + +export default memo(SettingsRealTimeQueries) diff --git a/src/components/SettingsRealTimeQueries.spec.jsx b/src/components/SettingsRealTimeQueries.spec.jsx new file mode 100644 index 00000000..22baff16 --- /dev/null +++ b/src/components/SettingsRealTimeQueries.spec.jsx @@ -0,0 +1,76 @@ +import { computeDocumentFromRealTime } from 'components/SettingsRealTimeQueries' + +describe('computeDocumentFromRealTime', () => { + const client = { + getDocumentFromState: jest.fn() + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should merge attributes from real time document with existing document in store', () => { + const docFromRealTime = { + _id: 'io.cozy.settings.instance', + _type: 'io.cozy.settings', + _rev: 'rev-2', + public_name: 'Alice2' + } + const docFromState = { + _id: 'io.cozy.settings.instance', + _type: 'io.cozy.settings', + _rev: 'rev-1', + attributes: { + public_name: 'Alice', + password_defined: true + } + } + client.getDocumentFromState.mockReturnValueOnce(docFromState) + + const result = computeDocumentFromRealTime(client, docFromRealTime) + + expect(result).toEqual({ + _id: 'io.cozy.settings.instance', + _type: 'io.cozy.settings', + _rev: 'rev-2', + public_name: 'Alice2', + attributes: { + public_name: 'Alice2', + password_defined: true + }, + meta: { + rev: 'rev-2' + } + }) + expect(client.getDocumentFromState).toHaveBeenCalledWith( + 'io.cozy.settings', + 'io.cozy.settings.instance' + ) + }) + + it('should add attributes from real time document if no existing document in store', () => { + const docFromRealTime = { + _id: 'io.cozy.settings.instance', + _type: 'io.cozy.settings', + _rev: 'rev-1', + public_name: 'Alice' + } + client.getDocumentFromState.mockReturnValueOnce(null) + + const result = computeDocumentFromRealTime(client, docFromRealTime) + + expect(result).toEqual({ + _id: 'io.cozy.settings.instance', + _type: 'io.cozy.settings', + _rev: 'rev-1', + public_name: 'Alice', + attributes: { + public_name: 'Alice' + } + }) + expect(client.getDocumentFromState).toHaveBeenCalledWith( + 'io.cozy.settings', + 'io.cozy.settings.instance' + ) + }) +})