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;