diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint_test.yaml similarity index 79% rename from .github/workflows/lint.yaml rename to .github/workflows/lint_test.yaml index d500b8e9..f3659a0f 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,6 @@ jobs: - name: Lint the code run: npm run lint +# 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.css b/blocks/edit/da-title/da-title.css index 58c45e03..1527ccec 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,80 @@ 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; + user-select: none; + -webkit-user-select: none; +} + +.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; @@ -120,3 +196,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/da-title/da-title.js b/blocks/edit/da-title/da-title.js index 17a738a9..956c50b4 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'); + } + + renderCollabUsers() { + 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.renderCollabUsers() : 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/index.js b/blocks/edit/prose/index.js index 0c70b076..a4c0e398 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -90,66 +90,94 @@ function setAEMDocInEditor(aemDoc, yXmlFragment, schema) { prosemirrorToYXmlFragment(fin, yXmlFragment); } -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}`; +function handleAwarenessUpdates(wsProvider, daTitle, win) { + const users = new Set(); - const opts = {}; + wsProvider.awareness.on('update', (delta) => { + delta.added.forEach((u) => users.add(u)); + delta.updated.forEach((u) => users.add(u)); + delta.removed.forEach((u) => users.delete(u)); - if (window.adobeIMS?.isSignedInUser()) { - opts.params = { Authorization: `Bearer ${window.adobeIMS.getAccessToken().token}` }; - } + const awarenessStates = wsProvider.awareness.getStates(); + const userNames = [...users].map((u) => awarenessStates.get(u)?.user?.name || 'Anonymous'); + daTitle.collabUsers = [...userNames].sort(); + }); - const wsProvider = new WebsocketProvider(server, roomName, ydoc, opts); + wsProvider.on('status', (st) => { daTitle.collabStatus = st.status; }); + win.addEventListener('online', () => { daTitle.collabStatus = 'online'; }); + win.addEventListener('offline', () => { daTitle.collabStatus = 'offline'; }); +} - const yXmlFragment = ydoc.getXmlFragment('prosemirror'); +export function createAwarenessStatusWidget(wsProvider, win) { + const daTitle = win.document.querySelector('da-title'); + handleAwarenessUpdates(wsProvider, daTitle, win); + return daTitle; +} +export function handleYDocUpdates({ + daTitle, editor, ydoc, path, schema, wsProvider, yXmlFragment, fnInitProse, +}, win = window, fnSetAEMDocInEditor = setAEMDocInEditor) { let firstUpdate = true; 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) { + fnSetAEMDocInEditor(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; - } - - ydoc.getMap('aem').delete(serverInvKey); - setAEMDocInEditor(svrUpdate, yXmlFragment, schema); - }, timeout); + // push update from the server: re-init document + delete daTitle.collabStatus; + delete daTitle.collabUsers; + ydoc.destroy(); + wsProvider.destroy(); + editor.innerHTML = ''; + 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 new file mode 100644 index 00000000..66a70941 --- /dev/null +++ b/test/blocks/edit/proseCollab.test.js @@ -0,0 +1,160 @@ +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', () => { + it('Test awareness status', () => { + const dat = {}; + const doc = { querySelector: (e) => (e === 'da-title' ? dat : 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 daTitle = pi.createAwarenessStatusWidget(wsp, win); + expect(daTitle).to.equal(dat); + + 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(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' }); + expect(daTitle.collabStatus).to.equal('connected'); + + expect(awarenessOnCalled.length).to.equal(1); + expect(awarenessOnCalled[0].n).to.equal('update'); + + const knownUser123 = { user: { name: 'Daffy Duck' } }; + awarenessStates.set(123, knownUser123); + const knownUser789 = { user: { name: 'Joe Bloggs' } }; + awarenessStates.set(789, knownUser789); + const delta = { + added: [111, 456, 789, 123], + removed: [456], + updated: [234], + }; + + 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(''); + }); +}); 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;