From 33cba7794621fd464a7cc74c2c746facbaeaf639 Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Wed, 28 Feb 2024 20:09:59 +0000 Subject: [PATCH 01/12] Show collab awareness status as icons just left of the publish button If the user is know their initial is displayed. If the user is not known a generic 'user' icon is displayed. When hovering over the icon the full name is shown as a tooltip. When collab is disconnected disable the editor until it reconnects. --- blocks/edit/da-title/da-title.css | 54 +++++++++++ .../img/Smock_CloudDisconnected_18_N.svg | 12 +++ .../edit/prose/img/Smock_CloudError_18_N.svg | 12 +++ blocks/edit/prose/img/Smock_Cloud_18_N.svg | 11 +++ .../Smock_RealTimeCustomerProfile_18_N.svg | 11 +++ blocks/edit/prose/index.js | 95 ++++++++++++++++++- 6 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg create mode 100644 blocks/edit/prose/img/Smock_CloudError_18_N.svg create mode 100644 blocks/edit/prose/img/Smock_Cloud_18_N.svg create mode 100644 blocks/edit/prose/img/Smock_RealTimeCustomerProfile_18_N.svg diff --git a/blocks/edit/da-title/da-title.css b/blocks/edit/da-title/da-title.css index 58c45e03..73de77f1 100644 --- a/blocks/edit/da-title/da-title.css +++ b/blocks/edit/da-title/da-title.css @@ -120,3 +120,57 @@ h1 { bottom: 22px; } } + +/* ------------------------------------- + Styles for the collab awarness widget + ------------------------------------- */ + +div.collab-awareness { + flex: 1; + display: flex; + align-self: center; + justify-content: flex-end; + padding-right: 16px; +} + +div.collab-other-users { + display: flex; + flex-direction: row-reverse; +} + +div.collab-other-users div { + display: flex; +} + +img.collab-icon { + width: 19px; + height: 19px; +} + + +div.collab-users { + display: flex; + flex-direction: row-reverse; +} + +div.collab-initial { + background-color: var(--color-accent); + border-color: var(--color-accent); + border-style: solid; + border-width: 1px; + border-radius: 50%; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + margin-top: 0.5px; +} + +div.collab-initial p { + color: white; + font-size: 14px; + font-weight: 700; + margin-bottom: 15px; +} + diff --git a/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg b/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg new file mode 100644 index 00000000..810c4782 --- /dev/null +++ b/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg @@ -0,0 +1,12 @@ + + + + + S CloudDisconnected 18 N + + + \ No newline at end of file diff --git a/blocks/edit/prose/img/Smock_CloudError_18_N.svg b/blocks/edit/prose/img/Smock_CloudError_18_N.svg new file mode 100644 index 00000000..10c36214 --- /dev/null +++ b/blocks/edit/prose/img/Smock_CloudError_18_N.svg @@ -0,0 +1,12 @@ + + + + + S CloudError 18 N + + + \ No newline at end of file diff --git a/blocks/edit/prose/img/Smock_Cloud_18_N.svg b/blocks/edit/prose/img/Smock_Cloud_18_N.svg new file mode 100644 index 00000000..b647d589 --- /dev/null +++ b/blocks/edit/prose/img/Smock_Cloud_18_N.svg @@ -0,0 +1,11 @@ + + + + + S Cloud 18 N + + \ No newline at end of file diff --git a/blocks/edit/prose/img/Smock_RealTimeCustomerProfile_18_N.svg b/blocks/edit/prose/img/Smock_RealTimeCustomerProfile_18_N.svg new file mode 100644 index 00000000..f2d7bfe6 --- /dev/null +++ b/blocks/edit/prose/img/Smock_RealTimeCustomerProfile_18_N.svg @@ -0,0 +1,11 @@ + + + + + S RealTimeCustomerProfile 18 N + + \ No newline at end of file diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index 0c70b076..76381bee 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -90,6 +90,85 @@ function setAEMDocInEditor(aemDoc, yXmlFragment, schema) { prosemirrorToYXmlFragment(fin, yXmlFragment); } +function handleAwarenessUpdates(wsProvider, statusDiv, initEditor, destroyEditor) { + const users = new Set(); + const usersDiv = statusDiv.querySelector('div.collab-users'); + + wsProvider.awareness.on('update', (delta) => { + const awarenessStates = wsProvider.awareness.getStates(); + + delta.added.forEach((u) => users.add(u)); + delta.updated.forEach((u) => users.add(u)); + delta.removed.forEach((u) => users.delete(u)); + + let html = ''; + for (const u of Array.from(users).sort()) { + const name = awarenessStates.get(u)?.user?.name; + if (name) { + const initial = name.toString().substring(0, 1); + html = html.concat(`

${initial}

`); + } else { + html = html.concat(`
+ Other active user
`); + } + } + usersDiv.innerHTML = html; + }); + + let currentlyConnected; + const connectionImg = statusDiv.querySelector('img.collab-connection'); + wsProvider.on('status', (st) => { + const proseEl = window.view.root.querySelector('.ProseMirror'); + const connected = st.status === 'connected'; + proseEl.setAttribute('contenteditable', connected); + proseEl.style['background-color'] = connected ? null : 'lightgrey'; + + switch (st.status) { + case 'connected': + connectionImg.src = '/blocks/edit/prose/img/Smock_Cloud_18_N.svg'; + + if (currentlyConnected === false) { + /* We are re-connecting, in that case re-initialise the editor and ydoc */ + + destroyEditor(); + initEditor(); + } + currentlyConnected = true; + break; + case 'connecting': + currentlyConnected = false; + connectionImg.src = '/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg'; + break; + default: + currentlyConnected = false; + connectionImg.src = '/blocks/edit/prose/img/Smock_CloudError_18_N.svg'; + break; + } + connectionImg.alt = st.status; + connectionImg.title = st.status; + }); +} + +function createAwarenessStatusWidget(wsProvider, fnInit, fnDestroy) { + const statusDiv = document.createElement('div'); + statusDiv.classList = 'collab-awareness'; + statusDiv.innerHTML = `
+
+
+
`; + + const container = window.document.querySelector('da-title').shadowRoot.children[0]; + container.insertBefore(statusDiv, container.children[1]); + + const fnDestroyEditor = () => { + statusDiv.remove(); + fnDestroy(); + }; + + handleAwarenessUpdates(wsProvider, statusDiv, fnInit, fnDestroyEditor); +} + export default function initProse({ editor, path }) { const schema = getSchema(); @@ -105,6 +184,16 @@ export default function initProse({ editor, path }) { } const wsProvider = new WebsocketProvider(server, roomName, ydoc, opts); + const initEditor = () => { + editor.innerHTML = ''; + initProse({ editor, path }); + }; + const destroyEditor = () => { + ydoc.destroy(); + wsProvider.destroy(); + }; + + createAwarenessStatusWidget(wsProvider, initEditor, destroyEditor); const yXmlFragment = ydoc.getXmlFragment('prosemirror'); @@ -137,8 +226,10 @@ export default function initProse({ editor, path }) { return; } - ydoc.getMap('aem').delete(serverInvKey); - setAEMDocInEditor(svrUpdate, yXmlFragment, schema); + const aemMap = ydoc.getMap('aem'); + aemMap.delete(serverInvKey); + aemMap.set('content', upd); + setAEMDocInEditor(upd, yXmlFragment, schema); }, timeout); } From 81ac4e4f585a5e3acaf19f29622b8679bc1392ef Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Fri, 1 Mar 2024 10:37:30 +0000 Subject: [PATCH 02/12] Do not grey out editor on disconnect Also react to network (dis)connects as reported by the browser. Make firstUpdate async to give the ydoc a chance to init itself. --- blocks/edit/prose/index.js | 118 ++++++++++++++----------------------- 1 file changed, 44 insertions(+), 74 deletions(-) diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index 76381bee..19e0c623 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -90,7 +90,25 @@ function setAEMDocInEditor(aemDoc, yXmlFragment, schema) { prosemirrorToYXmlFragment(fin, yXmlFragment); } -function handleAwarenessUpdates(wsProvider, statusDiv, initEditor, destroyEditor) { +function setConnectionStatus(connectionImg, status) { + switch (status) { + case 'connected': + case 'online': + connectionImg.src = '/blocks/edit/prose/img/Smock_Cloud_18_N.svg'; + break; + case 'connecting': + case 'offline': + connectionImg.src = '/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg'; + break; + default: + connectionImg.src = '/blocks/edit/prose/img/Smock_CloudError_18_N.svg'; + break; + } + connectionImg.alt = status; + connectionImg.title = status; +} + +function handleAwarenessUpdates(wsProvider, statusDiv) { const users = new Set(); const usersDiv = statusDiv.querySelector('div.collab-users'); @@ -116,41 +134,13 @@ function handleAwarenessUpdates(wsProvider, statusDiv, initEditor, destroyEditor usersDiv.innerHTML = html; }); - let currentlyConnected; const connectionImg = statusDiv.querySelector('img.collab-connection'); - wsProvider.on('status', (st) => { - const proseEl = window.view.root.querySelector('.ProseMirror'); - const connected = st.status === 'connected'; - proseEl.setAttribute('contenteditable', connected); - proseEl.style['background-color'] = connected ? null : 'lightgrey'; - - switch (st.status) { - case 'connected': - connectionImg.src = '/blocks/edit/prose/img/Smock_Cloud_18_N.svg'; - - if (currentlyConnected === false) { - /* We are re-connecting, in that case re-initialise the editor and ydoc */ - - destroyEditor(); - initEditor(); - } - currentlyConnected = true; - break; - case 'connecting': - currentlyConnected = false; - connectionImg.src = '/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg'; - break; - default: - currentlyConnected = false; - connectionImg.src = '/blocks/edit/prose/img/Smock_CloudError_18_N.svg'; - break; - } - connectionImg.alt = st.status; - connectionImg.title = st.status; - }); + wsProvider.on('status', (st) => setConnectionStatus(connectionImg, st.status)); + window.addEventListener('online', () => setConnectionStatus(connectionImg, 'online')); + window.addEventListener('offline', () => setConnectionStatus(connectionImg, 'offline')); } -function createAwarenessStatusWidget(wsProvider, fnInit, fnDestroy) { +function createAwarenessStatusWidget(wsProvider) { const statusDiv = document.createElement('div'); statusDiv.classList = 'collab-awareness'; statusDiv.innerHTML = `
@@ -161,12 +151,8 @@ function createAwarenessStatusWidget(wsProvider, fnInit, fnDestroy) { const container = window.document.querySelector('da-title').shadowRoot.children[0]; container.insertBefore(statusDiv, container.children[1]); - const fnDestroyEditor = () => { - statusDiv.remove(); - fnDestroy(); - }; - - handleAwarenessUpdates(wsProvider, statusDiv, fnInit, fnDestroyEditor); + handleAwarenessUpdates(wsProvider, statusDiv); + return statusDiv; } export default function initProse({ editor, path }) { @@ -184,16 +170,7 @@ export default function initProse({ editor, path }) { } const wsProvider = new WebsocketProvider(server, roomName, ydoc, opts); - const initEditor = () => { - editor.innerHTML = ''; - initProse({ editor, path }); - }; - const destroyEditor = () => { - ydoc.destroy(); - wsProvider.destroy(); - }; - - createAwarenessStatusWidget(wsProvider, initEditor, destroyEditor); + const statusDiv = createAwarenessStatusWidget(wsProvider); const yXmlFragment = ydoc.getXmlFragment('prosemirror'); @@ -201,36 +178,29 @@ export default function initProse({ editor, path }) { ydoc.on('update', (_, originWS) => { if (firstUpdate) { firstUpdate = false; - const aemMap = ydoc.getMap('aem'); - const current = aemMap.get('content'); - const inital = aemMap.get('initial'); - if (!current && inital) { - setAEMDocInEditor(inital, yXmlFragment, schema); - } + + // Do the following async to allow the ydoc to init itself with any + // changes coming from other editors + setTimeout(() => { + const aemMap = ydoc.getMap('aem'); + const current = aemMap.get('content'); + const inital = aemMap.get('initial'); + if (!current && inital) { + setAEMDocInEditor(inital, yXmlFragment, schema); + } + }, 1); } const serverInvKey = 'svrinv'; const svrUpdate = ydoc.getMap('aem').get(serverInvKey); if (svrUpdate) { - // push update from the server - - const timeout = ydoc.clientID % 2000; - // Wait a small amount of time that's different for each client to ensure - // they don't all apply it at the same time, as only one client needs to - // apply the server-based invalidation. - setTimeout(() => { - // Check the value on the map again, if it's gone another client has - // handled it already. - const upd = ydoc.getMap('aem').get(serverInvKey); - if (upd === undefined) { - return; - } - - const aemMap = ydoc.getMap('aem'); - aemMap.delete(serverInvKey); - aemMap.set('content', upd); - setAEMDocInEditor(upd, yXmlFragment, schema); - }, timeout); + // push update from the server: re-init document + statusDiv.remove(); + ydoc.destroy(); + wsProvider.destroy(); + editor.innerHTML = ''; + initProse({ editor, path }); + return; } if (originWS && originWS !== wsProvider) { From 67a123b57dcf7b9a96ce40aead88ee1975412f01 Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Tue, 5 Mar 2024 16:06:43 +0000 Subject: [PATCH 03/12] Updated pathDetails test to make the tests pass --- test/blocks/shared/pathDetails.test.js | 26 ++++++++++++++------------ web-test-runner.config.js | 4 ++-- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/test/blocks/shared/pathDetails.test.js b/test/blocks/shared/pathDetails.test.js index 128a1c79..f3444eb1 100644 --- a/test/blocks/shared/pathDetails.test.js +++ b/test/blocks/shared/pathDetails.test.js @@ -6,32 +6,32 @@ const { default: getPathDetails } = await import('../../../blocks/shared/pathDet describe('Path details', () => { describe('Full path details', () => { - describe('HTML path details', () => { + it('HTML path details', () => { const loc = { pathname: '/edit', hash: '#/adobe/aem-boilerplate/cool/page' }; const details = getPathDetails(loc); - expect(details.origin).to.equal('http://localhost:8787'); + expect(details.origin).to.equal('https://admin.da.live'); expect(details.owner).to.equal('adobe'); expect(details.repo).to.equal('aem-boilerplate'); expect(details.fullpath).to.equal('/adobe/aem-boilerplate/cool/page'); - expect(details.sourceUrl).to.equal('http://localhost:8787/source/adobe/aem-boilerplate/cool/page.html'); + expect(details.sourceUrl).to.equal('https://admin.da.live/source/adobe/aem-boilerplate/cool/page.html'); expect(details.contentUrl).to.equal('https://content.da.live/adobe/aem-boilerplate/cool/page'); expect(details.previewUrl).to.equal('https://main--aem-boilerplate--adobe.hlx.page/cool/page'); }); - describe('JSON path details', () => { + it('JSON path details', () => { const loc = { pathname: '/sheet', hash: '#/adobe/aem-boilerplate/cool/data' }; const details = getPathDetails(loc); - expect(details.sourceUrl).to.equal('http://localhost:8787/source/adobe/aem-boilerplate/cool/data.json'); + expect(details.sourceUrl).to.equal('https://admin.da.live/source/adobe/aem-boilerplate/cool/data.json'); expect(details.contentUrl).to.equal('https://content.da.live/adobe/aem-boilerplate/cool/data.json'); }); - describe('JPG path details', () => { + it('JPG path details', () => { const loc = { pathname: '/view', hash: '#/adobe/aem-boilerplate/cool/pic.jpg' }; const details = getPathDetails(loc); - expect(details.sourceUrl).to.equal('http://localhost:8787/source/adobe/aem-boilerplate/cool/pic.jpg'); + expect(details.sourceUrl).to.equal('https://admin.da.live/source/adobe/aem-boilerplate/cool/pic.jpg'); }); - describe('Top level path details', () => { + it('Top level path details', () => { const loc = { pathname: '/view', hash: '#/adobe/aem-boilerplate/pic.jpg' }; const details = getPathDetails(loc); expect(details.parentName).to.equal('aem-boilerplate'); @@ -40,7 +40,7 @@ describe('Path details', () => { }); describe('Repo only path details', () => { - describe('Path details', () => { + it('Path details', () => { const loc = { hash: '#/adobe/aem-boilerplate' }; const details = getPathDetails(loc); expect(details.previewUrl).to.equal('https://main--aem-boilerplate--adobe.hlx.page'); @@ -48,7 +48,7 @@ describe('Path details', () => { }); describe('Owner only path details', () => { - describe('Path details', () => { + it('Path details', () => { const loc = { hash: '#/adobe' }; const details = getPathDetails(loc); expect(details.previewUrl).to.not.exist; @@ -56,17 +56,19 @@ describe('Path details', () => { }); describe('IMS callback path details', () => { - describe('Path details', () => { + it('Path details', () => { const loc = { hash: '#old_hash' }; const details = getPathDetails(loc); expect(details).to.be.null; }); }); + /* describe('IMS callback path details', () => { - describe('Path details', () => { + it('Path details', () => { const details = getPathDetails(); expect(details).to.be.null; }); }); + */ }); diff --git a/web-test-runner.config.js b/web-test-runner.config.js index abd629d1..a94d9f58 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -25,7 +25,7 @@ export default { '**/deps/**', ], }, - plugins: [importMapsPlugin({})], + plugins: [importMapsPlugin({ inject: { importMap: { imports: { 'da-y-wrapper': '/deps/da-y-wrapper/dist/index.js' } } } })], reporters: [ defaultReporter({ reportTestResults: true, reportTestProgress: true }), customReporter(), @@ -44,7 +44,7 @@ export default { } return oldFetch.call(window, resource, options); }; - + const oldXHROpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = async function (...args) { let [method, url, asyn] = args; From 1456aaf6c431c67ff800d8705c790d2e97eb8f28 Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Tue, 5 Mar 2024 16:13:48 +0000 Subject: [PATCH 04/12] Add running the tests to github action --- .github/workflows/{lint.yaml => lint_test.yaml} | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename .github/workflows/{lint.yaml => lint_test.yaml} (90%) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint_test.yaml similarity index 90% rename from .github/workflows/lint.yaml rename to .github/workflows/lint_test.yaml index d500b8e9..ec2a6ed3 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint_test.yaml @@ -1,4 +1,4 @@ -name: Lint +name: Lint and Test on: push: branches: @@ -29,3 +29,5 @@ jobs: - name: Lint the code run: npm run lint + - name: Run the tests + run: npm t From 49d1714be386510108a3027fc8e263c314e82c12 Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Tue, 5 Mar 2024 16:20:52 +0000 Subject: [PATCH 05/12] Add tests for blocks/edit/prose/index.js --- blocks/edit/prose/index.js | 16 ++-- test/blocks/edit/proseCollab.test.js | 125 +++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 test/blocks/edit/proseCollab.test.js diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index 19e0c623..8d5ee9dd 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -90,7 +90,7 @@ function setAEMDocInEditor(aemDoc, yXmlFragment, schema) { prosemirrorToYXmlFragment(fin, yXmlFragment); } -function setConnectionStatus(connectionImg, status) { +export function setConnectionStatus(connectionImg, status) { switch (status) { case 'connected': case 'online': @@ -108,7 +108,7 @@ function setConnectionStatus(connectionImg, status) { connectionImg.title = status; } -function handleAwarenessUpdates(wsProvider, statusDiv) { +function handleAwarenessUpdates(wsProvider, statusDiv, win) { const users = new Set(); const usersDiv = statusDiv.querySelector('div.collab-users'); @@ -136,22 +136,22 @@ function handleAwarenessUpdates(wsProvider, statusDiv) { const connectionImg = statusDiv.querySelector('img.collab-connection'); wsProvider.on('status', (st) => setConnectionStatus(connectionImg, st.status)); - window.addEventListener('online', () => setConnectionStatus(connectionImg, 'online')); - window.addEventListener('offline', () => setConnectionStatus(connectionImg, 'offline')); + win.addEventListener('online', () => setConnectionStatus(connectionImg, 'online')); + win.addEventListener('offline', () => setConnectionStatus(connectionImg, 'offline')); } -function createAwarenessStatusWidget(wsProvider) { - const statusDiv = document.createElement('div'); +export function createAwarenessStatusWidget(wsProvider, win = window) { + const statusDiv = win.document.createElement('div'); statusDiv.classList = 'collab-awareness'; statusDiv.innerHTML = `
`; - const container = window.document.querySelector('da-title').shadowRoot.children[0]; + const container = win.document.querySelector('da-title').shadowRoot.children[0]; container.insertBefore(statusDiv, container.children[1]); - handleAwarenessUpdates(wsProvider, statusDiv); + handleAwarenessUpdates(wsProvider, statusDiv, win); return statusDiv; } diff --git a/test/blocks/edit/proseCollab.test.js b/test/blocks/edit/proseCollab.test.js new file mode 100644 index 00000000..27171bfc --- /dev/null +++ b/test/blocks/edit/proseCollab.test.js @@ -0,0 +1,125 @@ +import { expect } from '@esm-bundle/chai'; + +// This is needed to make a dynamic import work that is indirectly referenced +// from edit/prose/index.js +const { setLibs } = await import('../../../scripts/utils.js'); +setLibs('/bheuaark/', { hostname: 'localhost' }); + +const pi = await import('../../../blocks/edit/prose/index.js'); + +describe('Prose collab', () => { + const testConenctionStatus = (status) => { + const mockImg = {}; + + pi.setConnectionStatus(mockImg, status); + + expect(mockImg.alt).to.equal(status); + expect(mockImg.title).to.equal(status); + return mockImg.src; + }; + + it('Test setConnectionStatus connected', async () => { + expect(testConenctionStatus('connected')) + .to.equal('/blocks/edit/prose/img/Smock_Cloud_18_N.svg'); + expect(testConenctionStatus('online')) + .to.equal('/blocks/edit/prose/img/Smock_Cloud_18_N.svg'); + expect(testConenctionStatus('connecting')) + .to.equal('/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg'); + expect(testConenctionStatus('offline')) + .to.equal('/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg'); + expect(testConenctionStatus('anythingelse')) + .to.equal('/blocks/edit/prose/img/Smock_CloudError_18_N.svg'); + }); + + it('Test awareness status', () => { + const c0 = {}; + const c1 = {}; + const contInserted = []; + const cont = { + children: [c0, c1], + insertBefore: (e, r) => { + if (r === c1) { + contInserted.push(e); + } + }, + }; + const tsr = { children: [cont] }; + const titleEl = { + shadowRoot: tsr, + }; + + const dcu = {}; + const connImg = {}; + const divEl = { + querySelector: (e) => { + switch (e) { + case 'div.collab-users': return dcu; + case 'img.collab-connection': return connImg; + default: return null; + } + }, + }; + const doc = { + createElement: (e) => (e === 'div' ? divEl : null), + querySelector: (e) => (e === 'da-title' ? titleEl : null), + }; + const winEventListeners = []; + const win = { + addEventListener: (n, f) => winEventListeners.push({ n, f }), + document: doc, + }; + + const awarenessOnCalled = []; + const awarenessStates = new Map(); + const awareness = { + getStates: () => awarenessStates, + on: (n, f) => awarenessOnCalled.push({ n, f }), + }; + const wspOnCalled = []; + const wsp = { + awareness, + on: (n, f) => wspOnCalled.push({ n, f }), + }; + + const stdiv = pi.createAwarenessStatusWidget(wsp, win); + expect(stdiv.classList).to.equal('collab-awareness'); + expect(stdiv.innerHTML).to.contain(''); + expect(contInserted).to.deep.equal([divEl]); + + // check all 'called' arrays + expect(winEventListeners.length).to.equal(2); + const el0 = winEventListeners[0]; + const el1 = winEventListeners[1]; + const elOnline = el0.n === 'online' ? el0 : el1; + const elOffline = el0.n === 'offline' ? el0 : el1; + + elOnline.f(); // Call the callback function sent to the listener + expect(connImg.title).to.equal('online'); + elOffline.f(); + expect(connImg.title).to.equal('offline'); + + expect(wspOnCalled.length).to.equal(1); + expect(wspOnCalled[0].n).to.equal('status'); + wspOnCalled[0].f({ status: 'connected' }); // Call the callback function + expect(connImg.title).to.equal('connected'); + + // Check the awareness callback + expect(awarenessOnCalled.length).to.equal(1); + expect(awarenessOnCalled[0].n).to.equal('update'); + + const knownUser = { user: { name: 'Joe Bloggs' } }; + awarenessStates.set(789, knownUser); + const delta = { + added: [123, 456, 789], + removed: [456], + updated: [234], + }; + + awarenessOnCalled[0].f(delta); // Call the callback function + // Should contain 123, 234, 789 + expect(dcu.innerHTML).to.contain('alt="123"'); + expect(dcu.innerHTML).to.contain('alt="234"'); + expect(dcu.innerHTML).to.contain('class="collab-initial" title="Joe Bloggs">

J

'); + expect(dcu.innerHTML).to.not.contain('789'); + }); +}); From eff912dc4da4827107dc8e510e45d602005b874c Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Tue, 5 Mar 2024 16:22:41 +0000 Subject: [PATCH 06/12] Fix linting problem --- test/blocks/edit/proseCollab.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/blocks/edit/proseCollab.test.js b/test/blocks/edit/proseCollab.test.js index 27171bfc..01b848e3 100644 --- a/test/blocks/edit/proseCollab.test.js +++ b/test/blocks/edit/proseCollab.test.js @@ -44,9 +44,7 @@ describe('Prose collab', () => { }, }; const tsr = { children: [cont] }; - const titleEl = { - shadowRoot: tsr, - }; + const titleEl = { shadowRoot: tsr }; const dcu = {}; const connImg = {}; From faacea52c1126a38786fa758a72610e340f58b3c Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Tue, 5 Mar 2024 16:36:45 +0000 Subject: [PATCH 07/12] Back out pathDetails.test.js changes --- test/blocks/shared/pathDetails.test.js | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/test/blocks/shared/pathDetails.test.js b/test/blocks/shared/pathDetails.test.js index f3444eb1..128a1c79 100644 --- a/test/blocks/shared/pathDetails.test.js +++ b/test/blocks/shared/pathDetails.test.js @@ -6,32 +6,32 @@ const { default: getPathDetails } = await import('../../../blocks/shared/pathDet describe('Path details', () => { describe('Full path details', () => { - it('HTML path details', () => { + describe('HTML path details', () => { const loc = { pathname: '/edit', hash: '#/adobe/aem-boilerplate/cool/page' }; const details = getPathDetails(loc); - expect(details.origin).to.equal('https://admin.da.live'); + expect(details.origin).to.equal('http://localhost:8787'); expect(details.owner).to.equal('adobe'); expect(details.repo).to.equal('aem-boilerplate'); expect(details.fullpath).to.equal('/adobe/aem-boilerplate/cool/page'); - expect(details.sourceUrl).to.equal('https://admin.da.live/source/adobe/aem-boilerplate/cool/page.html'); + expect(details.sourceUrl).to.equal('http://localhost:8787/source/adobe/aem-boilerplate/cool/page.html'); expect(details.contentUrl).to.equal('https://content.da.live/adobe/aem-boilerplate/cool/page'); expect(details.previewUrl).to.equal('https://main--aem-boilerplate--adobe.hlx.page/cool/page'); }); - it('JSON path details', () => { + describe('JSON path details', () => { const loc = { pathname: '/sheet', hash: '#/adobe/aem-boilerplate/cool/data' }; const details = getPathDetails(loc); - expect(details.sourceUrl).to.equal('https://admin.da.live/source/adobe/aem-boilerplate/cool/data.json'); + expect(details.sourceUrl).to.equal('http://localhost:8787/source/adobe/aem-boilerplate/cool/data.json'); expect(details.contentUrl).to.equal('https://content.da.live/adobe/aem-boilerplate/cool/data.json'); }); - it('JPG path details', () => { + describe('JPG path details', () => { const loc = { pathname: '/view', hash: '#/adobe/aem-boilerplate/cool/pic.jpg' }; const details = getPathDetails(loc); - expect(details.sourceUrl).to.equal('https://admin.da.live/source/adobe/aem-boilerplate/cool/pic.jpg'); + expect(details.sourceUrl).to.equal('http://localhost:8787/source/adobe/aem-boilerplate/cool/pic.jpg'); }); - it('Top level path details', () => { + describe('Top level path details', () => { const loc = { pathname: '/view', hash: '#/adobe/aem-boilerplate/pic.jpg' }; const details = getPathDetails(loc); expect(details.parentName).to.equal('aem-boilerplate'); @@ -40,7 +40,7 @@ describe('Path details', () => { }); describe('Repo only path details', () => { - it('Path details', () => { + describe('Path details', () => { const loc = { hash: '#/adobe/aem-boilerplate' }; const details = getPathDetails(loc); expect(details.previewUrl).to.equal('https://main--aem-boilerplate--adobe.hlx.page'); @@ -48,7 +48,7 @@ describe('Path details', () => { }); describe('Owner only path details', () => { - it('Path details', () => { + describe('Path details', () => { const loc = { hash: '#/adobe' }; const details = getPathDetails(loc); expect(details.previewUrl).to.not.exist; @@ -56,19 +56,17 @@ describe('Path details', () => { }); describe('IMS callback path details', () => { - it('Path details', () => { + describe('Path details', () => { const loc = { hash: '#old_hash' }; const details = getPathDetails(loc); expect(details).to.be.null; }); }); - /* describe('IMS callback path details', () => { - it('Path details', () => { + describe('Path details', () => { const details = getPathDetails(); expect(details).to.be.null; }); }); - */ }); From 4406106ff20b5b2100d82f3243877ec5ac3b6742 Mon Sep 17 00:00:00 2001 From: Chris Millar Date: Tue, 5 Mar 2024 19:26:50 -0700 Subject: [PATCH 08/12] Collab UX Polish * Simplified integration between prose/index and da-title. * prose/index only responsible for data, da-title is presentation. * Turned user icon into clickable initial pill. Clicking shows full name popover. * Cloud is also clickable for more detailed status --- blocks/edit/da-title/da-title.css | 78 +++++++++++++++- blocks/edit/da-title/da-title.js | 89 ++++++++++++++----- .../edit/img/Smock_CloudDisconnected_18_N.svg | 7 ++ blocks/edit/img/Smock_CloudError_18_N.svg | 7 ++ blocks/edit/img/Smock_Cloud_18_N.svg | 7 ++ .../img/Smock_CloudDisconnected_18_N.svg | 12 --- .../edit/prose/img/Smock_CloudError_18_N.svg | 12 --- blocks/edit/prose/img/Smock_Cloud_18_N.svg | 11 --- .../Smock_RealTimeCustomerProfile_18_N.svg | 11 --- blocks/edit/prose/index.js | 63 +++---------- 10 files changed, 178 insertions(+), 119 deletions(-) create mode 100644 blocks/edit/img/Smock_CloudDisconnected_18_N.svg create mode 100644 blocks/edit/img/Smock_CloudError_18_N.svg create mode 100644 blocks/edit/img/Smock_Cloud_18_N.svg delete mode 100644 blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg delete mode 100644 blocks/edit/prose/img/Smock_CloudError_18_N.svg delete mode 100644 blocks/edit/prose/img/Smock_Cloud_18_N.svg delete mode 100644 blocks/edit/prose/img/Smock_RealTimeCustomerProfile_18_N.svg diff --git a/blocks/edit/da-title/da-title.css b/blocks/edit/da-title/da-title.css index 73de77f1..6ec64a98 100644 --- a/blocks/edit/da-title/da-title.css +++ b/blocks/edit/da-title/da-title.css @@ -3,6 +3,10 @@ padding: 48px 0; } +:host > svg { + display: none; +} + h1 { margin-top: 0; } @@ -54,7 +58,7 @@ h1 { opacity: 1; } -.da-title-name-label:before { +.da-title-name-label::before { display: block; content: ''; position: absolute; @@ -65,8 +69,78 @@ h1 { background: url('/blocks/edit/img/left-large.svg') center/18px no-repeat; } -.da-title-actions { +.da-title-collab-actions-wrapper { + display: flex; margin-bottom: 0.67em; +} + +.collab-status { + display: flex; + align-items: center; + justify-content: end; +} + +.collab-icon { + position: relative; + font-size: 12px; + font-weight: 700; +} + +.collab-icon:hover { + z-index: 2; +} + +.collab-icon.collab-popup::after { + display: block; + content: attr(data-popup-content); + position: absolute; + bottom: -32px; + left: 50%; + transform: translateX(-50%); + text-align: center; + text-transform: capitalize; + background: #676767; + color: #FFF; + white-space: nowrap; + padding: 0 8px; + border-radius: 4px; +} + +.collab-icon-user { + height: 24px; + border-radius: 12px; + background: rgb(171 171 171 / 50%); + display: flex; + align-items: center; + justify-content: center; + text-transform: uppercase; + color: #676767; + margin-right: -6px; + padding: 0 12px; +} + +.collab-icon-user:hover { + background: rgb(150 150 150 / 50%); +} + +.collab-status-cloud { + height: 27px; + margin-left: -4px; + margin-bottom: -3px; + color: rgb(20 115 230 / 80%); +} + +.collab-icon.collab-status-cloud.collab-popup::after { + bottom: -29px; +} + +.collab-status-cloud svg { + pointer-events: none; + width: 37.5px; + height: 27px; +} + +.da-title-actions { right: -12px; position: relative; border: 12px solid transparent; diff --git a/blocks/edit/da-title/da-title.js b/blocks/edit/da-title/da-title.js index 17a738a9..d1cf05fd 100644 --- a/blocks/edit/da-title/da-title.js +++ b/blocks/edit/da-title/da-title.js @@ -1,12 +1,28 @@ -import { LitElement, html } from '../../../deps/lit/lit-core.min.js'; +import { LitElement, html, nothing } from '../../../deps/lit/lit-core.min.js'; import { saveToDa, saveToAem } from '../utils/helpers.js'; +import inlinesvg from '../../shared/inlinesvg.js'; import getSheet from '../../shared/sheet.js'; const sheet = await getSheet('/blocks/edit/da-title/da-title.css'); +const ICONS = [ + '/blocks/edit/img/Smock_Cloud_18_N.svg', + '/blocks/edit/img/Smock_CloudDisconnected_18_N.svg', + '/blocks/edit/img/Smock_CloudError_18_N.svg', +]; + +const CLOUD_ICONS = { + connected: 'spectrum-Cloud-connected', + offline: 'spectrum-Cloud-offline', + connecting: 'spectrum-Cloud-error', + error: 'spectrum-Cloud-error', +}; + export default class DaTitle extends LitElement { static properties = { details: { attribute: false }, + collabStatus: { attribute: false }, + collabUsers: { attribute: false }, _actionsVis: {}, }; @@ -14,6 +30,7 @@ export default class DaTitle extends LitElement { super.connectedCallback(); this.shadowRoot.adoptedStyleSheets = [sheet]; this._actionsVis = false; + inlinesvg({ parent: this.shadowRoot, paths: ICONS }); } async handleAction(action) { @@ -48,6 +65,35 @@ export default class DaTitle extends LitElement { this._actionsVis = !this._actionsVis; } + popover({ target }) { + // If toggling off, simply remove; + if (target.classList.contains('collab-popup')) { + target.classList.remove('collab-popup'); + return; + } + // Find all open popups and close them + const openPopups = this.shadowRoot.querySelectorAll('.collab-popup'); + openPopups.forEach((pop) => { pop.classList.remove('collab-popup'); }); + target.classList.add('collab-popup'); + } + + renderColabUsers() { + return html`${this.collabUsers.map((user) => { + const initials = user.split(' ').map((name) => name.toString().substring(0, 1)); + return html`
${initials.join('')}
`; + })}`; + } + + renderCollab() { + return html` +
+ ${this.collabUsers && this.collabUsers.length > 1 ? this.renderColabUsers() : nothing} +
+ +
+
`; + } + render() { return html`
@@ -58,25 +104,28 @@ export default class DaTitle extends LitElement { class="da-title-name-label">${this.details.parentName}

${this.details.name}

-
- - - +
+ ${this.collabStatus ? this.renderCollab() : nothing} +
+ + + +
`; diff --git a/blocks/edit/img/Smock_CloudDisconnected_18_N.svg b/blocks/edit/img/Smock_CloudDisconnected_18_N.svg new file mode 100644 index 00000000..7132c866 --- /dev/null +++ b/blocks/edit/img/Smock_CloudDisconnected_18_N.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/blocks/edit/img/Smock_CloudError_18_N.svg b/blocks/edit/img/Smock_CloudError_18_N.svg new file mode 100644 index 00000000..e2bb47eb --- /dev/null +++ b/blocks/edit/img/Smock_CloudError_18_N.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/blocks/edit/img/Smock_Cloud_18_N.svg b/blocks/edit/img/Smock_Cloud_18_N.svg new file mode 100644 index 00000000..a4ea1434 --- /dev/null +++ b/blocks/edit/img/Smock_Cloud_18_N.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg b/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg deleted file mode 100644 index 810c4782..00000000 --- a/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - S CloudDisconnected 18 N - - - \ No newline at end of file diff --git a/blocks/edit/prose/img/Smock_CloudError_18_N.svg b/blocks/edit/prose/img/Smock_CloudError_18_N.svg deleted file mode 100644 index 10c36214..00000000 --- a/blocks/edit/prose/img/Smock_CloudError_18_N.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - S CloudError 18 N - - - \ No newline at end of file diff --git a/blocks/edit/prose/img/Smock_Cloud_18_N.svg b/blocks/edit/prose/img/Smock_Cloud_18_N.svg deleted file mode 100644 index b647d589..00000000 --- a/blocks/edit/prose/img/Smock_Cloud_18_N.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - S Cloud 18 N - - \ No newline at end of file diff --git a/blocks/edit/prose/img/Smock_RealTimeCustomerProfile_18_N.svg b/blocks/edit/prose/img/Smock_RealTimeCustomerProfile_18_N.svg deleted file mode 100644 index f2d7bfe6..00000000 --- a/blocks/edit/prose/img/Smock_RealTimeCustomerProfile_18_N.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - S RealTimeCustomerProfile 18 N - - \ No newline at end of file diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index 8d5ee9dd..2c65c258 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -90,27 +90,8 @@ function setAEMDocInEditor(aemDoc, yXmlFragment, schema) { prosemirrorToYXmlFragment(fin, yXmlFragment); } -export function setConnectionStatus(connectionImg, status) { - switch (status) { - case 'connected': - case 'online': - connectionImg.src = '/blocks/edit/prose/img/Smock_Cloud_18_N.svg'; - break; - case 'connecting': - case 'offline': - connectionImg.src = '/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg'; - break; - default: - connectionImg.src = '/blocks/edit/prose/img/Smock_CloudError_18_N.svg'; - break; - } - connectionImg.alt = status; - connectionImg.title = status; -} - -function handleAwarenessUpdates(wsProvider, statusDiv, win) { +function handleAwarenessUpdates(wsProvider, daTitle, win) { const users = new Set(); - const usersDiv = statusDiv.querySelector('div.collab-users'); wsProvider.awareness.on('update', (delta) => { const awarenessStates = wsProvider.awareness.getStates(); @@ -119,40 +100,19 @@ function handleAwarenessUpdates(wsProvider, statusDiv, win) { delta.updated.forEach((u) => users.add(u)); delta.removed.forEach((u) => users.delete(u)); - let html = ''; - for (const u of Array.from(users).sort()) { - const name = awarenessStates.get(u)?.user?.name; - if (name) { - const initial = name.toString().substring(0, 1); - html = html.concat(`

${initial}

`); - } else { - html = html.concat(`
- Other active user
`); - } - } - usersDiv.innerHTML = html; + const sortedUsers = [...users].sort(); + daTitle.collabUsers = sortedUsers.map((u) => awarenessStates.get(u)?.user?.name || 'Anonymous'); }); - const connectionImg = statusDiv.querySelector('img.collab-connection'); - wsProvider.on('status', (st) => setConnectionStatus(connectionImg, st.status)); - win.addEventListener('online', () => setConnectionStatus(connectionImg, 'online')); - win.addEventListener('offline', () => setConnectionStatus(connectionImg, 'offline')); + wsProvider.on('status', (st) => { daTitle.collabStatus = st.status; }); + win.addEventListener('online', () => { daTitle.collabStatus = 'online'; }); + win.addEventListener('offline', () => { daTitle.collabStatus = 'offline'; }); } export function createAwarenessStatusWidget(wsProvider, win = window) { - const statusDiv = win.document.createElement('div'); - statusDiv.classList = 'collab-awareness'; - statusDiv.innerHTML = `
-
-
-
`; - - const container = win.document.querySelector('da-title').shadowRoot.children[0]; - container.insertBefore(statusDiv, container.children[1]); - - handleAwarenessUpdates(wsProvider, statusDiv, win); - return statusDiv; + const daTitle = win.document.querySelector('da-title'); + handleAwarenessUpdates(wsProvider, daTitle, win); + return daTitle; } export default function initProse({ editor, path }) { @@ -170,7 +130,7 @@ export default function initProse({ editor, path }) { } const wsProvider = new WebsocketProvider(server, roomName, ydoc, opts); - const statusDiv = createAwarenessStatusWidget(wsProvider); + const daTitle = createAwarenessStatusWidget(wsProvider); const yXmlFragment = ydoc.getXmlFragment('prosemirror'); @@ -195,7 +155,8 @@ export default function initProse({ editor, path }) { const svrUpdate = ydoc.getMap('aem').get(serverInvKey); if (svrUpdate) { // push update from the server: re-init document - statusDiv.remove(); + delete daTitle.collabStatus; + delete daTitle.collabUsers; ydoc.destroy(); wsProvider.destroy(); editor.innerHTML = ''; From 1918b80689ddd2580829482a1110a5d27c3a1364 Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Wed, 6 Mar 2024 10:26:06 +0000 Subject: [PATCH 09/12] Fix unit tests --- blocks/edit/prose/index.js | 7 +-- test/blocks/edit/proseCollab.test.js | 86 +++++----------------------- 2 files changed, 18 insertions(+), 75 deletions(-) diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index 2c65c258..3881ed95 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -94,14 +94,13 @@ function handleAwarenessUpdates(wsProvider, daTitle, win) { const users = new Set(); wsProvider.awareness.on('update', (delta) => { - const awarenessStates = wsProvider.awareness.getStates(); - delta.added.forEach((u) => users.add(u)); delta.updated.forEach((u) => users.add(u)); delta.removed.forEach((u) => users.delete(u)); - const sortedUsers = [...users].sort(); - daTitle.collabUsers = sortedUsers.map((u) => awarenessStates.get(u)?.user?.name || 'Anonymous'); + const awarenessStates = wsProvider.awareness.getStates(); + const userNames = users.values().map((u) => awarenessStates.get(u)?.user?.name || 'Anonymous'); + daTitle.collabUsers = [...userNames].sort(); }); wsProvider.on('status', (st) => { daTitle.collabStatus = st.status; }); diff --git a/test/blocks/edit/proseCollab.test.js b/test/blocks/edit/proseCollab.test.js index 01b848e3..64a2a317 100644 --- a/test/blocks/edit/proseCollab.test.js +++ b/test/blocks/edit/proseCollab.test.js @@ -8,59 +8,9 @@ setLibs('/bheuaark/', { hostname: 'localhost' }); const pi = await import('../../../blocks/edit/prose/index.js'); describe('Prose collab', () => { - const testConenctionStatus = (status) => { - const mockImg = {}; - - pi.setConnectionStatus(mockImg, status); - - expect(mockImg.alt).to.equal(status); - expect(mockImg.title).to.equal(status); - return mockImg.src; - }; - - it('Test setConnectionStatus connected', async () => { - expect(testConenctionStatus('connected')) - .to.equal('/blocks/edit/prose/img/Smock_Cloud_18_N.svg'); - expect(testConenctionStatus('online')) - .to.equal('/blocks/edit/prose/img/Smock_Cloud_18_N.svg'); - expect(testConenctionStatus('connecting')) - .to.equal('/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg'); - expect(testConenctionStatus('offline')) - .to.equal('/blocks/edit/prose/img/Smock_CloudDisconnected_18_N.svg'); - expect(testConenctionStatus('anythingelse')) - .to.equal('/blocks/edit/prose/img/Smock_CloudError_18_N.svg'); - }); - it('Test awareness status', () => { - const c0 = {}; - const c1 = {}; - const contInserted = []; - const cont = { - children: [c0, c1], - insertBefore: (e, r) => { - if (r === c1) { - contInserted.push(e); - } - }, - }; - const tsr = { children: [cont] }; - const titleEl = { shadowRoot: tsr }; - - const dcu = {}; - const connImg = {}; - const divEl = { - querySelector: (e) => { - switch (e) { - case 'div.collab-users': return dcu; - case 'img.collab-connection': return connImg; - default: return null; - } - }, - }; - const doc = { - createElement: (e) => (e === 'div' ? divEl : null), - querySelector: (e) => (e === 'da-title' ? titleEl : null), - }; + const dat = {}; + const doc = { querySelector: (e) => (e === 'da-title' ? dat : null) }; const winEventListeners = []; const win = { addEventListener: (n, f) => winEventListeners.push({ n, f }), @@ -79,12 +29,9 @@ describe('Prose collab', () => { on: (n, f) => wspOnCalled.push({ n, f }), }; - const stdiv = pi.createAwarenessStatusWidget(wsp, win); - expect(stdiv.classList).to.equal('collab-awareness'); - expect(stdiv.innerHTML).to.contain(''); - expect(contInserted).to.deep.equal([divEl]); + const daTitle = pi.createAwarenessStatusWidget(wsp, win); + expect(daTitle).to.equal(dat); - // check all 'called' arrays expect(winEventListeners.length).to.equal(2); const el0 = winEventListeners[0]; const el1 = winEventListeners[1]; @@ -92,32 +39,29 @@ describe('Prose collab', () => { const elOffline = el0.n === 'offline' ? el0 : el1; elOnline.f(); // Call the callback function sent to the listener - expect(connImg.title).to.equal('online'); - elOffline.f(); - expect(connImg.title).to.equal('offline'); + expect(daTitle.collabStatus).to.equal('online'); + elOffline.f(); // Call the callback function sent to the listener + expect(daTitle.collabStatus).to.equal('offline'); expect(wspOnCalled.length).to.equal(1); expect(wspOnCalled[0].n).to.equal('status'); - wspOnCalled[0].f({ status: 'connected' }); // Call the callback function - expect(connImg.title).to.equal('connected'); + wspOnCalled[0].f({ status: 'connected' }); + expect(daTitle.collabStatus).to.equal('connected'); - // Check the awareness callback expect(awarenessOnCalled.length).to.equal(1); expect(awarenessOnCalled[0].n).to.equal('update'); - const knownUser = { user: { name: 'Joe Bloggs' } }; - awarenessStates.set(789, knownUser); + const knownUser123 = { user: { name: 'Daffy Duck' } }; + awarenessStates.set(123, knownUser123); + const knownUser789 = { user: { name: 'Joe Bloggs' } }; + awarenessStates.set(789, knownUser789); const delta = { - added: [123, 456, 789], + added: [111, 456, 789, 123], removed: [456], updated: [234], }; awarenessOnCalled[0].f(delta); // Call the callback function - // Should contain 123, 234, 789 - expect(dcu.innerHTML).to.contain('alt="123"'); - expect(dcu.innerHTML).to.contain('alt="234"'); - expect(dcu.innerHTML).to.contain('class="collab-initial" title="Joe Bloggs">

J

'); - expect(dcu.innerHTML).to.not.contain('789'); + expect(daTitle.collabUsers).to.deep.equal(['Anonymous', 'Anonymous', 'Daffy Duck', 'Joe Bloggs']); }); }); From 45576d52bfc054caa24bd2c87cd77ce505b17f92 Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Wed, 6 Mar 2024 15:08:47 +0000 Subject: [PATCH 10/12] Additional unit tests --- .github/workflows/lint_test.yaml | 5 +- blocks/edit/da-title/da-title.js | 4 +- blocks/edit/prose/index.js | 53 +++++++++------- test/blocks/edit/proseCollab.test.js | 93 ++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 27 deletions(-) diff --git a/.github/workflows/lint_test.yaml b/.github/workflows/lint_test.yaml index ec2a6ed3..f3659a0f 100644 --- a/.github/workflows/lint_test.yaml +++ b/.github/workflows/lint_test.yaml @@ -29,5 +29,6 @@ jobs: - name: Lint the code run: npm run lint - - name: Run the tests - run: npm t +# Commenting out running the tests until all existing test failures have been fixed +# - name: Run the tests +# run: npm t diff --git a/blocks/edit/da-title/da-title.js b/blocks/edit/da-title/da-title.js index d1cf05fd..956c50b4 100644 --- a/blocks/edit/da-title/da-title.js +++ b/blocks/edit/da-title/da-title.js @@ -77,7 +77,7 @@ export default class DaTitle extends LitElement { target.classList.add('collab-popup'); } - renderColabUsers() { + renderCollabUsers() { return html`${this.collabUsers.map((user) => { const initials = user.split(' ').map((name) => name.toString().substring(0, 1)); return html`
${initials.join('')}
`; @@ -87,7 +87,7 @@ export default class DaTitle extends LitElement { renderCollab() { return html`
- ${this.collabUsers && this.collabUsers.length > 1 ? this.renderColabUsers() : nothing} + ${this.collabUsers && this.collabUsers.length > 1 ? this.renderCollabUsers() : nothing}
diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index 3881ed95..086cd69e 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -108,31 +108,15 @@ function handleAwarenessUpdates(wsProvider, daTitle, win) { win.addEventListener('offline', () => { daTitle.collabStatus = 'offline'; }); } -export function createAwarenessStatusWidget(wsProvider, win = window) { +export function createAwarenessStatusWidget(wsProvider, win) { const daTitle = win.document.querySelector('da-title'); handleAwarenessUpdates(wsProvider, daTitle, win); return daTitle; } -export default function initProse({ editor, path }) { - const schema = getSchema(); - - const ydoc = new Y.Doc(); - - const server = COLLAB_ORIGIN; - const roomName = `${DA_ORIGIN}${new URL(path).pathname}`; - - const opts = {}; - - if (window.adobeIMS?.isSignedInUser()) { - opts.params = { Authorization: `Bearer ${window.adobeIMS.getAccessToken().token}` }; - } - - const wsProvider = new WebsocketProvider(server, roomName, ydoc, opts); - const daTitle = createAwarenessStatusWidget(wsProvider); - - const yXmlFragment = ydoc.getXmlFragment('prosemirror'); - +export function handleYDocUpdates({ + daTitle, editor, ydoc, path, schema, wsProvider, yXmlFragment, fnInitProse, +}, win = window, fnSetAEMDocInEditor = setAEMDocInEditor) { let firstUpdate = true; ydoc.on('update', (_, originWS) => { if (firstUpdate) { @@ -145,7 +129,7 @@ export default function initProse({ editor, path }) { const current = aemMap.get('content'); const inital = aemMap.get('initial'); if (!current && inital) { - setAEMDocInEditor(inital, yXmlFragment, schema); + fnSetAEMDocInEditor(inital, yXmlFragment, schema); } }, 1); } @@ -159,18 +143,41 @@ export default function initProse({ editor, path }) { ydoc.destroy(); wsProvider.destroy(); editor.innerHTML = ''; - initProse({ editor, path }); + fnInitProse({ editor, path }); return; } if (originWS && originWS !== wsProvider) { - const proseEl = window.view.root.querySelector('.ProseMirror'); + const proseEl = win.view.root.querySelector('.ProseMirror'); const clone = proseEl.cloneNode(true); const aem = prose2aem(clone); const aemMap = ydoc.getMap('aem'); aemMap.set('content', aem); } }); +} + +export default function initProse({ editor, path }) { + const schema = getSchema(); + + const ydoc = new Y.Doc(); + + const server = COLLAB_ORIGIN; + const roomName = `${DA_ORIGIN}${new URL(path).pathname}`; + + const opts = {}; + + if (window.adobeIMS?.isSignedInUser()) { + opts.params = { Authorization: `Bearer ${window.adobeIMS.getAccessToken().token}` }; + } + + const wsProvider = new WebsocketProvider(server, roomName, ydoc, opts); + const daTitle = createAwarenessStatusWidget(wsProvider, window); + + const yXmlFragment = ydoc.getXmlFragment('prosemirror'); + handleYDocUpdates({ + daTitle, editor, ydoc, path, schema, wsProvider, yXmlFragment, initProse, + }); if (window.adobeIMS?.isSignedInUser()) { window.adobeIMS.getProfile().then( diff --git a/test/blocks/edit/proseCollab.test.js b/test/blocks/edit/proseCollab.test.js index 64a2a317..66a70941 100644 --- a/test/blocks/edit/proseCollab.test.js +++ b/test/blocks/edit/proseCollab.test.js @@ -64,4 +64,97 @@ describe('Prose collab', () => { awarenessOnCalled[0].f(delta); // Call the callback function expect(daTitle.collabUsers).to.deep.equal(['Anonymous', 'Anonymous', 'Daffy Duck', 'Joe Bloggs']); }); + + it('Test YDoc firstUpdate callback', (done) => { + const ydocMap = new Map(); + ydocMap.set('initial', 'Some intial text'); + + const ydocOnCalls = []; + const ydoc = { + getMap: (n) => (n === 'aem' ? ydocMap : null), + on: (n, f) => ydocOnCalls.push({ n, f }), + }; + + const setAEMDocCalls = []; + const fnSetAEMDoc = () => setAEMDocCalls.push('called'); + + pi.handleYDocUpdates({ + daTitle: {}, + editor: {}, + ydoc, + path: {}, + schema: {}, + wsProvider: {}, + yXmlFragment: {}, + fnInitProse: () => {}, + }, {}, fnSetAEMDoc); + expect(ydocOnCalls.length).to.equal(1); + expect(ydocOnCalls[0].n).to.equal('update'); + + ydocOnCalls[0].f(); + setTimeout(() => { + expect(setAEMDocCalls).to.deep.equal(['called']); + + // the function call again, it should not perform any action this time + ydocOnCalls[0].f(); + setTimeout(() => { + expect(setAEMDocCalls).to.deep.equal( + ['called'], + 'First update code should only be called once', + ); + done(); + }, 200); + }, 200); + }); + + it('Test YDoc server update callback', () => { + const daTitle = { + collabStatus: 'yeah', + collabUsers: 'some', + }; + const editor = {}; + + const ydocMap = new Map(); + ydocMap.set('svrinv', 'Some svrinv text'); + + const ydocCalls = []; + const ydocOnCalls = []; + const ydoc = { + getMap: (n) => (n === 'aem' ? ydocMap : null), + destroy: () => ydocCalls.push('destroy'), + on: (n, f) => ydocOnCalls.push({ n, f }), + }; + + const wspCalls = []; + const wsp = { destroy: () => wspCalls.push('destroy') }; + + const initProseCalls = []; + const mockInitProse = () => initProseCalls.push('init'); + + pi.handleYDocUpdates({ + daTitle, + editor, + ydoc, + path: {}, + schema: {}, + wsProvider: wsp, + yXmlFragment: {}, + fnInitProse: mockInitProse, + }, {}, () => {}); + expect(ydocOnCalls.length).to.equal(1); + expect(ydocOnCalls[0].n).to.equal('update'); + + expect(daTitle.collabStatus).to.equal('yeah', 'Precondition'); + expect(daTitle.collabUsers).to.equal('some', 'Precondition'); + + // Calls server invalidation + ydocOnCalls[0].f(); + + expect(daTitle.collabStatus).to.be.undefined; + expect(daTitle.collabUsers).to.be.undefined; + expect(ydocCalls).to.deep.equal(['destroy']); + expect(wspCalls).to.deep.equal(['destroy']); + expect(initProseCalls).to.deep.equal(['init']); + expect(editor.innerHTML).to.equal(''); + }); }); From a69556fff25648674a56e7421941b241f6bd5c01 Mon Sep 17 00:00:00 2001 From: David Bosschaert Date: Wed, 6 Mar 2024 15:17:36 +0000 Subject: [PATCH 11/12] Fix user mapping --- blocks/edit/prose/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index 086cd69e..a4c0e398 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -99,7 +99,7 @@ function handleAwarenessUpdates(wsProvider, daTitle, win) { delta.removed.forEach((u) => users.delete(u)); const awarenessStates = wsProvider.awareness.getStates(); - const userNames = users.values().map((u) => awarenessStates.get(u)?.user?.name || 'Anonymous'); + const userNames = [...users].map((u) => awarenessStates.get(u)?.user?.name || 'Anonymous'); daTitle.collabUsers = [...userNames].sort(); }); From 7ae810b654c68877242b432b615385f1f6b07683 Mon Sep 17 00:00:00 2001 From: Karl Pauls Date: Wed, 6 Mar 2024 16:52:07 +0100 Subject: [PATCH 12/12] Set user icon to not be selectable --- blocks/edit/da-title/da-title.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/blocks/edit/da-title/da-title.css b/blocks/edit/da-title/da-title.css index 6ec64a98..1527ccec 100644 --- a/blocks/edit/da-title/da-title.css +++ b/blocks/edit/da-title/da-title.css @@ -117,6 +117,8 @@ h1 { color: #676767; margin-right: -6px; padding: 0 12px; + user-select: none; + -webkit-user-select: none; } .collab-icon-user:hover {