-
[[i18n('driveEditButton.dialog.heading')]]
-
+
+
+
[[i18n('driveButton.dialog.heading')]]
+
[[i18n('driveButton.dialog.description')]]
+
+ [[i18n('driveButton.dialog.install.prompt')]]
+
+
+
+ [[i18n('driveButton.install.dialog.hint')]]
+
-
[[i18n('driveUpload.directTransfer.failed')]]
+
`;
}
@@ -80,39 +131,54 @@ class NuxeoDriveUploadButton extends mixinBehaviors([I18nBehavior, FiltersBehavi
if (!doc) return false;
return (
- this.hasPermission &&
- this.hasFacet &&
- this.isProxy &&
- this.hasPermission(doc, 'Write') &&
- this.hasFacet(doc, 'Folderish') &&
+ this.hasPermission?.(doc, 'Write') &&
+ this.hasFacet?.(doc, 'Folderish') &&
+ this.isProxy != null &&
!this.isProxy(doc)
);
}
_go() {
- this.$.token
- .get()
- .then((response) => {
- const tokens = response.entries.map((token) => token.id);
- if (!tokens || !tokens.length) {
- this.$.dialog.toggle();
- return;
- }
- window.open(this.directTransferUrl, '_top');
- })
- .catch((error) => {
- console.error('Token fetch failed:', error);
- this.$.toast.toggle();
- });
+ try {
+ this._installExpanded = false;
+ navigateAndShowFallback(this, this.directTransferUrl);
+ } catch (e) {
+ this._showError(e.message);
+ }
+ }
+
+ _toggleInstall(e) {
+ e.preventDefault();
+ this._installExpanded = true;
+ }
+
+ _showError(message) {
+ this.$.toast.text = message;
+ this.$.toast.open();
}
get directTransferUrl() {
- const finalUrl = [
- 'nxdrive://direct-transfer',
- baseUrl.split('/ui/')[0].replace('://', '/'),
- this.document.path.slice(1),
- ].join('/');
- return finalUrl;
+ return this._compressUploadUrl();
+ }
+
+ _compressUploadUrl() {
+ const cleanBaseUrl = baseUrl.split('/ui/')[0].replace(/\/$/, '');
+ const isHttps = cleanBaseUrl.startsWith('https') ? 1 : 0;
+ const serverHost = cleanBaseUrl.replace('://', '/').split('/').slice(1).join('/');
+
+ const serverBytes = new TextEncoder().encode(serverHost);
+ if (serverBytes.length > 255) {
+ const msg = this.i18n('driveUpload.serverUrlTooLong');
+ this._showError(msg);
+ throw new Error(msg);
+ }
+
+ const docPath = this.document.path.startsWith('/') ? this.document.path.slice(1) : this.document.path;
+ const pathBytes = new TextEncoder().encode(docPath);
+
+ const payload = new Uint8Array([isHttps, serverBytes.length, ...serverBytes, ...pathBytes]);
+ const b64 = base64UrlSafeEncode(payload);
+ return `nxdrive://direct-transfer/${b64}`;
}
}
diff --git a/addons/nuxeo-drive/elements/nuxeo-drive-utils.js b/addons/nuxeo-drive/elements/nuxeo-drive-utils.js
new file mode 100644
index 0000000000..55f5c91f16
--- /dev/null
+++ b/addons/nuxeo-drive/elements/nuxeo-drive-utils.js
@@ -0,0 +1,71 @@
+/**
+©2023 Hyland Software, Inc. and its affiliates. All rights reserved.
+All Hyland product names are registered or unregistered trademarks of Hyland Software, Inc. or its affiliates.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Shared utilities for nuxeo-drive action buttons.
+ *
+ * Centralises the token-fetch / dialog-toggle sequence and the URL-safe Base64
+ * encoder that are otherwise duplicated across nuxeo-drive-edit-button,
+ * nuxeo-drive-download-button, and nuxeo-drive-upload-button.
+ */
+
+/**
+ * Navigates to the given nxdrive:// URL and opens the install-help dialog.
+ *
+ * This follows the "navigate-first" pattern (like Zoom/Slack/Spotify):
+ * - The browser fires the custom protocol immediately; if Drive is installed
+ * the OS shows a native "Open app?" prompt on top of the dialog.
+ * - The dialog stays visible behind the prompt with a subtle install hint,
+ * so if Drive is *not* installed the user can expand the install links.
+ * - If Drive opened successfully the user simply closes the dialog.
+ *
+ * No detection hacks, timeouts, or environment-specific code.
+ *
+ * @param {Object} element - The Polymer element instance (must expose $.dialog).
+ * @param {string} driveUrl - The nxdrive:// URL to navigate to.
+ */
+export function navigateAndShowFallback(element, driveUrl) {
+ // Navigate immediately — the browser / OS handles the custom protocol prompt.
+ // We use an anchor click instead of location.href to avoid a full page reload
+ // when the custom protocol is not registered (e.g. nxdrive:// on a machine
+ // without Nuxeo Drive). Anchor clicks with unknown schemes are silently
+ // ignored by the browser rather than triggering a navigation error.
+ const a = document.createElement('a');
+ a.href = driveUrl;
+ a.click();
+
+ // Open the install-help dialog behind the browser's native prompt.
+ if (!element.$.dialog.opened) {
+ element.$.dialog.toggle();
+ }
+}
+
+/**
+ * Encodes a Uint8Array as a URL-safe Base64 string (no padding).
+ *
+ * @param {Uint8Array} bytes
+ * @returns {string}
+ */
+export function base64UrlSafeEncode(bytes) {
+ let binary = '';
+ bytes.forEach((byte) => {
+ binary += String.fromCodePoint(byte);
+ });
+ const b64 = btoa(binary).replaceAll('+', '-').replaceAll('/', '_');
+ const padStart = b64.indexOf('=');
+ return padStart === -1 ? b64 : b64.slice(0, padStart);
+}
diff --git a/addons/nuxeo-drive/test/nuxeo-drive-download-button.test.js b/addons/nuxeo-drive/test/nuxeo-drive-download-button.test.js
index f1d7aacf38..52f63bd637 100644
--- a/addons/nuxeo-drive/test/nuxeo-drive-download-button.test.js
+++ b/addons/nuxeo-drive/test/nuxeo-drive-download-button.test.js
@@ -18,20 +18,19 @@ limitations under the License.
import { fixture, flush, html } from '@nuxeo/testing-helpers';
import { PageProviderDisplayBehavior } from '@nuxeo/nuxeo-ui-elements/nuxeo-page-provider-display-behavior.js';
import '../elements/nuxeo-drive-download-button.js';
+import { setupI18n, stubToast, addShowErrorSuite, addToggleInstallSuite } from './nuxeo-drive-test-helpers.test.js';
// Setup i18n keys used by the component
-window.nuxeo = window.nuxeo || {};
-window.nuxeo.I18n = window.nuxeo.I18n || {};
-window.nuxeo.I18n.language = 'en';
-window.nuxeo.I18n.en = window.nuxeo.I18n.en || {};
-window.nuxeo.I18n.en['driveDownloadButton.tooltip'] = 'Download with Nuxeo Drive';
-window.nuxeo.I18n.en['driveDownload.noDocumentsSelected'] = 'No documents selected for download.';
-window.nuxeo.I18n.en['driveDownload.tooManyDocuments'] =
- 'You have selected more documents than supported. Please select up to {0} documents to download via Nuxeo Drive.';
-window.nuxeo.I18n.en['driveDownload.directTransfer.failed'] =
- 'An error occurred while trying to download the document with Nuxeo Drive.';
-window.nuxeo.I18n.en['driveEditButton.dialog.heading'] = 'Download Nuxeo Drive Client';
-window.nuxeo.I18n.en['command.close'] = 'Close';
+setupI18n({
+ 'driveDownloadButton.tooltip': 'Download with Nuxeo Drive',
+ 'driveDownload.noDocumentsSelected': 'No documents selected for download.',
+ 'driveDownload.tooManyDocuments':
+ 'You have selected more documents than supported. Please select up to {0} documents to download via Nuxeo Drive.',
+ 'driveDownload.directTransfer.failed': 'An error occurred while trying to download the document with Nuxeo Drive.',
+ 'driveButton.dialog.heading': 'Nuxeo Drive',
+ 'driveButton.dialog.description': 'Use Nuxeo Drive to work with your documents directly from your desktop.',
+ 'command.close': 'Close',
+});
suite('nuxeo-drive-download-button', () => {
let element;
@@ -40,6 +39,10 @@ suite('nuxeo-drive-download-button', () => {
element = await fixture(html`
`);
});
+ // Shared suites
+ addShowErrorSuite(() => element);
+ addToggleInstallSuite(() => element);
+
// ---------------------------------------------------------------------------
// _isAvailable
// ---------------------------------------------------------------------------
@@ -88,14 +91,11 @@ suite('nuxeo-drive-download-button', () => {
await flush();
const actionDiv = element.shadowRoot.querySelector('.action');
expect(actionDiv).to.exist;
- // hidden$ binding: hidden attribute should NOT be set
expect(actionDiv.hasAttribute('hidden')).to.be.false;
});
test('action div is hidden when _isAvailable returns false', async () => {
- // Stub _isAvailable to return false (simulates select-all active)
sinon.stub(element, '_isAvailable').returns(false);
- // Re-trigger the binding by setting documents
element.documents = [];
await flush();
const actionDiv = element.shadowRoot.querySelector('.action');
@@ -155,9 +155,7 @@ suite('nuxeo-drive-download-button', () => {
expect(element._getSelectedDocumentUids()).to.deep.equal([]);
});
- test('returns UIDs from selectedItems (not items) when select-all is active and some items are deselected', () => {
- // Simulates: select-all on 36 docs, then deselect 14 → 22 remain selected.
- // selectAllActive stays true; selectedItems reflects the actual selection.
+ test('returns UIDs from selectedItems when select-all is active and some items are deselected', () => {
const viewStub = {
selectAllActive: true,
behaviors: [...PageProviderDisplayBehavior],
@@ -180,7 +178,6 @@ suite('nuxeo-drive-download-button', () => {
selectAllActive: true,
behaviors: [...PageProviderDisplayBehavior],
items: [{ uid: 'item-uid-1' }, { uid: 'item-uid-2' }],
- // no selectedItems property
};
element.documents = viewStub;
expect(element._getSelectedDocumentUids()).to.deep.equal(['item-uid-1', 'item-uid-2']);
@@ -208,6 +205,14 @@ suite('nuxeo-drive-download-button', () => {
expect(url).to.include('00000000-0000-0000-0000-000000000001');
expect(url).to.include('00000000-0000-0000-0000-000000000002');
});
+
+ test('URL contains a server/host segment after the direct-download scheme', () => {
+ element.documents = [{ uid: '00000000-0000-0000-0000-000000000001' }];
+ const url = element._buildOriginalUrl();
+ const path = url.replace('nxdrive://direct-download/', '');
+ const segments = path.split('/');
+ expect(segments.length).to.be.at.least(2);
+ });
});
// ---------------------------------------------------------------------------
@@ -242,29 +247,11 @@ suite('nuxeo-drive-download-button', () => {
element.documents = [{ uid: '00000000-1111-2222-3333-444444444444' }];
expect(element.directDownloadUrl).to.match(/^nxdrive:\/\/direct-download\/[A-Za-z0-9_-]+$/);
});
- });
- // ---------------------------------------------------------------------------
- // _base64UrlSafeEncode
- // ---------------------------------------------------------------------------
- suite('_base64UrlSafeEncode', () => {
- test('output contains no standard base64 padding (=)', () => {
- const bytes = new Uint8Array([1, 2, 3, 4, 5]);
- const result = element._base64UrlSafeEncode(bytes);
- expect(result).to.not.include('=');
- });
-
- test('output contains no + characters (URL-safe)', () => {
- // Use bytes that would produce '+' in standard base64
- const bytes = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 200));
- const result = element._base64UrlSafeEncode(bytes);
- expect(result).to.not.include('+');
- });
-
- test('output contains no / characters (URL-safe)', () => {
- const bytes = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 200));
- const result = element._base64UrlSafeEncode(bytes);
- expect(result).to.not.include('/');
+ test('throws when server host segment exceeds 255 bytes', () => {
+ const longServer = 'http/' + 'a'.repeat(260) + '/00000000-1111-2222-3333-444444444444';
+ const longUrl = `nxdrive://direct-download/${longServer}`;
+ expect(() => element._compressFromOriginalUrl(longUrl)).to.throw();
});
});
@@ -275,15 +262,14 @@ suite('nuxeo-drive-download-button', () => {
let toastStub;
setup(() => {
- toastStub = { text: '', open: sinon.spy() };
- sinon.stub(element.$, 'toast').value(toastStub);
+ toastStub = stubToast(element);
});
teardown(() => {
sinon.restore();
});
- test('shows noDocumentsSelected error when documents is empty and document is unset', async () => {
+ test('shows noDocumentsSelected error when documents is empty and document is unset', () => {
element.documents = [];
element.document = null;
element._download();
@@ -291,24 +277,6 @@ suite('nuxeo-drive-download-button', () => {
expect(toastStub.text).to.include('No documents selected');
});
- test('triggers download for all items in view when select-all is active', async () => {
- const viewStub = {
- selectAllActive: true,
- behaviors: [...PageProviderDisplayBehavior],
- items: [{ uid: 'sa-uid-1' }, { uid: 'sa-uid-2' }, { uid: 'sa-uid-3' }],
- };
- element.documents = viewStub;
- sinon.stub(element.$.token, 'get').resolves({ entries: [{ id: 'token-abc' }] });
- const openStub = sinon.stub(window, 'open');
-
- element._download();
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- expect(toastStub.open).to.not.have.been.called;
- expect(openStub).to.have.been.calledOnce;
- expect(openStub.firstCall.args[1]).to.equal('_top');
- });
-
test('shows noDocumentsSelected error when select-all is active but view has no items', () => {
const viewStub = {
selectAllActive: true,
@@ -335,32 +303,7 @@ suite('nuxeo-drive-download-button', () => {
expect(toastStub.text).to.include('25');
});
- test('succeeds after deselecting items so count drops to ≤ 25, even though selectAllActive stays true', async () => {
- // Simulates the bug fix: select-all on 36 docs → deselect 13 → 23 selectedItems.
- // selectAllActive remains true but selectedItems has only 23 entries.
- const viewStub = {
- selectAllActive: true,
- behaviors: [...PageProviderDisplayBehavior],
- items: Array.from({ length: 36 }, (_, i) => {
- return { uid: `uid-${i}` };
- }),
- selectedItems: Array.from({ length: 23 }, (_, i) => {
- return { uid: `uid-${i}` };
- }),
- };
- element.documents = viewStub;
- sinon.stub(element.$.token, 'get').resolves({ entries: [{ id: 'token-abc' }] });
- const openStub = sinon.stub(window, 'open');
-
- element._download();
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- expect(toastStub.open).to.not.have.been.called;
- expect(openStub).to.have.been.calledOnce;
- expect(openStub.firstCall.args[1]).to.equal('_top');
- });
-
- test('shows tooManyDocuments error when more than 25 documents are selected', async () => {
+ test('shows tooManyDocuments error when more than 25 documents are selected', () => {
element.documents = Array.from({ length: 26 }, (_, i) => {
return { uid: `uid-${i}` };
});
@@ -373,53 +316,34 @@ suite('nuxeo-drive-download-button', () => {
element.documents = Array.from({ length: 25 }, (_, i) => {
return { uid: `uid-${i}` };
});
- // Stub token.get to prevent real network call; stub window.open before _download is called
- sinon.stub(element.$.token, 'get').returns(new Promise(() => {})); // never resolves — prevents window.open
element._download();
- // The toast should not have been opened at this point (no guard condition triggered)
expect(toastStub.open).to.not.have.been.called;
});
- test('calls window.open with directDownloadUrl when a valid Drive token exists', async () => {
- element.documents = [{ uid: 'doc-uid-1' }, { uid: 'doc-uid-2' }];
- sinon.stub(element.$.token, 'get').resolves({ entries: [{ id: 'token-abc' }] });
- const openStub = sinon.stub(window, 'open');
-
- element._download();
- // Let the promise chain resolve
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- expect(openStub).to.have.been.calledOnce;
- const calledUrl = openStub.firstCall.args[0];
- expect(calledUrl).to.match(/^nxdrive:\/\/direct-download\/[A-Za-z0-9_-]+$/);
- expect(openStub.firstCall.args[1]).to.equal('_top');
- });
-
- test('opens Drive install dialog when no Drive token is found', async () => {
- element.documents = [{ uid: 'doc-uid-1' }];
- sinon.stub(element.$.token, 'get').resolves({ entries: [] });
- const dialogToggleStub = sinon.stub(element.$.dialog, 'toggle');
-
+ test('succeeds after deselecting items so count drops to ≤ 25', () => {
+ const viewStub = {
+ selectAllActive: true,
+ behaviors: [...PageProviderDisplayBehavior],
+ items: Array.from({ length: 36 }, (_, i) => {
+ return { uid: `uid-${i}` };
+ }),
+ selectedItems: Array.from({ length: 23 }, (_, i) => {
+ return { uid: `uid-${i}` };
+ }),
+ };
+ element.documents = viewStub;
element._download();
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- expect(dialogToggleStub).to.have.been.calledOnce;
expect(toastStub.open).to.not.have.been.called;
});
- test('shows directTransfer.failed error when token.get rejects', async () => {
- element.documents = [{ uid: 'doc-uid-1' }];
- sinon.stub(element.$.token, 'get').rejects(new Error('network error'));
-
+ test('does not show error for a single document via document property', () => {
+ element.documents = [];
+ element.document = { uid: 'single-doc-uid' };
element._download();
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- expect(toastStub.open).to.have.been.calledOnce;
- expect(toastStub.text).to.include('error occurred');
+ expect(toastStub.open).to.not.have.been.called;
});
- test('folder UID is collected as a single item (folder = one ID)', () => {
- // Folders are treated as a single document ID — no enumeration of contents
+ test('folder UID is collected as a single item', () => {
element.documents = [{ uid: 'folder-uid-1' }, { uid: 'folder-uid-2' }];
const uids = element._getSelectedDocumentUids();
expect(uids).to.deep.equal(['folder-uid-1', 'folder-uid-2']);
@@ -440,34 +364,33 @@ suite('nuxeo-drive-download-button', () => {
expect(url).to.include('folder-uid-2');
});
- test('single document action (via document property) triggers download with correct UID', async () => {
- element.documents = [];
- element.document = { uid: 'single-doc-uid' };
- sinon.stub(element.$.token, 'get').resolves({ entries: [{ id: 'token-abc' }] });
- const openStub = sinon.stub(window, 'open');
-
+ test('triggers download for select-all items within limit', () => {
+ const viewStub = {
+ selectAllActive: true,
+ behaviors: [...PageProviderDisplayBehavior],
+ items: [{ uid: 'sa-uid-1' }, { uid: 'sa-uid-2' }, { uid: 'sa-uid-3' }],
+ };
+ element.documents = viewStub;
element._download();
- await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(toastStub.open).to.not.have.been.called;
+ });
- expect(openStub).to.have.been.calledOnce;
- // Verify the UID is encoded in the compressed URL by checking the uncompressed URL
- const originalUrl = element._buildOriginalUrl();
- expect(originalUrl).to.include('single-doc-uid');
+ test('shows error with userMessage when _compressFromOriginalUrl throws with userMessage', () => {
+ element.documents = [{ uid: 'doc-uid-1' }];
+ const err = new Error('internal error');
+ err.userMessage = 'The server URL is too long';
+ sinon.stub(element, '_compressFromOriginalUrl').throws(err);
+ element._download();
+ expect(toastStub.open).to.have.been.calledOnce;
+ expect(toastStub.text).to.equal('The server URL is too long');
});
- });
- // ---------------------------------------------------------------------------
- // _buildOriginalUrl — server info
- // ---------------------------------------------------------------------------
- suite('_buildOriginalUrl — server info', () => {
- test('URL contains a server/host segment after the direct-download scheme', () => {
- element.documents = [{ uid: '00000000-0000-0000-0000-000000000001' }];
- const url = element._buildOriginalUrl();
- // Format: nxdrive://direct-download/
//.../
- // After stripping the nxdrive://direct-download/ prefix there should be at least 2 more segments
- const path = url.replace('nxdrive://direct-download/', '');
- const segments = path.split('/');
- expect(segments.length).to.be.at.least(2);
+ test('shows error with message when _compressFromOriginalUrl throws without userMessage', () => {
+ element.documents = [{ uid: 'doc-uid-1' }];
+ sinon.stub(element, '_compressFromOriginalUrl').throws(new Error('generic error'));
+ element._download();
+ expect(toastStub.open).to.have.been.calledOnce;
+ expect(toastStub.text).to.equal('generic error');
});
});
});
diff --git a/addons/nuxeo-drive/test/nuxeo-drive-edit-button.test.js b/addons/nuxeo-drive/test/nuxeo-drive-edit-button.test.js
new file mode 100644
index 0000000000..739c53c782
--- /dev/null
+++ b/addons/nuxeo-drive/test/nuxeo-drive-edit-button.test.js
@@ -0,0 +1,131 @@
+/**
+@license
+©2023 Hyland Software, Inc. and its affiliates. All rights reserved.
+All Hyland product names are registered or unregistered trademarks of Hyland Software, Inc. or its affiliates.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+import { fixture, html } from '@nuxeo/testing-helpers';
+import '../elements/nuxeo-drive-edit-button.js';
+import { setupI18n, addShowErrorSuite, addToggleInstallSuite, addGoSuite } from './nuxeo-drive-test-helpers.test.js';
+
+// Setup i18n keys used by the component
+setupI18n({
+ 'driveEditButton.tooltip': 'Open with Nuxeo Drive',
+ 'driveEditButton.directTransfer.failed': 'An error occurred while trying to open the document with Nuxeo Drive.',
+ 'driveEditButton.dialog.heading': 'Download Nuxeo Drive Client',
+ 'driveButton.dialog.heading': 'Nuxeo Drive',
+ 'driveButton.dialog.description': 'Use Nuxeo Drive to work with your documents directly from your desktop.',
+ 'command.close': 'Close',
+});
+
+suite('nuxeo-drive-edit-button', () => {
+ let element;
+
+ setup(async () => {
+ element = await fixture(html``);
+ element.user = { id: 'Administrator' };
+ element.document = { uid: 'doc-uid-1', repository: 'default' };
+ element.blob = { data: 'http://localhost/nxfile/default/doc-uid-1/file:content/test.docx', name: 'test.docx' };
+ });
+
+ // Shared suites
+ addShowErrorSuite(() => element);
+ addToggleInstallSuite(() => element);
+ addGoSuite(() => element);
+
+ // ---------------------------------------------------------------------------
+ // _isAvailable — branch coverage
+ // ---------------------------------------------------------------------------
+ suite('_isAvailable', () => {
+ const baseDoc = () => {
+ return {
+ uid: 'doc-1',
+ facets: [],
+ contextParameters: { permissions: ['Write', 'Read'] },
+ };
+ };
+
+ const blobWithNoAppLinks = { data: 'http://localhost/nxfile/default/doc-1/file:content/test.docx', appLinks: [] };
+
+ test('returns false when blob is null', () => {
+ expect(element._isAvailable(baseDoc(), null)).to.not.be.ok;
+ });
+
+ test('returns false when blob is undefined', () => {
+ expect(element._isAvailable(baseDoc(), undefined)).to.not.be.ok;
+ });
+
+ test('returns false when blob.appLinks is non-empty', () => {
+ const blobWithLinks = { ...blobWithNoAppLinks, appLinks: [{ name: 'SomeApp', url: 'someapp://open' }] };
+ expect(element._isAvailable(baseDoc(), blobWithLinks)).to.be.false;
+ });
+
+ test('returns false when doc lacks Write permission', () => {
+ const doc = { uid: 'doc-1', facets: [], contextParameters: { permissions: ['Read'] } };
+ expect(element._isAvailable(doc, blobWithNoAppLinks)).to.be.false;
+ });
+
+ test('returns false when doc is a proxy', () => {
+ const doc = {
+ uid: 'doc-1',
+ facets: ['Immutable', 'HiddenInNavigation'],
+ isProxy: true,
+ contextParameters: { permissions: ['Write'] },
+ };
+ expect(element._isAvailable(doc, blobWithNoAppLinks)).to.be.false;
+ });
+
+ test('returns true when blob has no appLinks property at all', () => {
+ const blobNoAppLinksKey = { data: 'http://localhost/nxfile/default/doc-1/file:content/test.docx' };
+ expect(element._isAvailable(baseDoc(), blobNoAppLinksKey)).to.be.true;
+ });
+
+ test('returns true when all conditions are met', () => {
+ expect(element._isAvailable(baseDoc(), blobWithNoAppLinks)).to.be.true;
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // driveEditURL
+ // ---------------------------------------------------------------------------
+ suite('driveEditURL', () => {
+ test('returns empty string when blob is not set', () => {
+ element.blob = null;
+ expect(element.driveEditURL).to.equal('');
+ });
+
+ test('returns empty string when blob is undefined', () => {
+ element.blob = undefined;
+ expect(element.driveEditURL).to.equal('');
+ });
+
+ test('builds a valid nxdrive://edit URL from blob data', () => {
+ const url = element.driveEditURL;
+ expect(url).to.match(/^nxdrive:\/\/edit\//);
+ expect(url).to.include('Administrator');
+ expect(url).to.include('doc-uid-1');
+ expect(url).to.include('default');
+ expect(url).to.include('test.docx');
+ });
+
+ test('encodes the filename in the URL', () => {
+ element.blob = {
+ data: 'http://localhost/nxfile/default/doc-uid-1/file:content/my%20doc.docx',
+ name: 'my doc.docx',
+ };
+ const url = element.driveEditURL;
+ expect(url).to.include(encodeURIComponent('my doc.docx'));
+ });
+ });
+});
diff --git a/addons/nuxeo-drive/test/nuxeo-drive-test-helpers.test.js b/addons/nuxeo-drive/test/nuxeo-drive-test-helpers.test.js
new file mode 100644
index 0000000000..113ca820f3
--- /dev/null
+++ b/addons/nuxeo-drive/test/nuxeo-drive-test-helpers.test.js
@@ -0,0 +1,168 @@
+/**
+@license
+©2023 Hyland Software, Inc. and its affiliates. All rights reserved.
+All Hyland product names are registered or unregistered trademarks of Hyland Software, Inc. or its affiliates.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Shared helpers for nuxeo-drive unit tests.
+ *
+ * Centralises repeated patterns (i18n setup, stub factories, async helpers, and
+ * common test-suite factories) so individual test files stay free of duplication.
+ */
+
+// Prevent nxdrive:// anchor clicks from triggering a Karma page reload.
+// navigateAndShowFallback() creates an anchor and clicks it; this no-op
+// stops the browser from actually following the custom-protocol link.
+HTMLAnchorElement.prototype.click = function () {};
+
+// ---------------------------------------------------------------------------
+// i18n bootstrap
+// ---------------------------------------------------------------------------
+
+/**
+ * Initialises the minimum globalThis.nuxeo.I18n structure required by
+ * nuxeo-drive components and merges the supplied key map.
+ *
+ * @param {Object} keys - Map of i18n key → English value.
+ */
+export function setupI18n(keys) {
+ globalThis.nuxeo = globalThis.nuxeo || {};
+ globalThis.nuxeo.I18n = globalThis.nuxeo.I18n || {};
+ globalThis.nuxeo.I18n.language = 'en';
+ globalThis.nuxeo.I18n.en = globalThis.nuxeo.I18n.en || {};
+ Object.assign(globalThis.nuxeo.I18n.en, keys);
+}
+
+// ---------------------------------------------------------------------------
+// Async helpers
+// ---------------------------------------------------------------------------
+
+/** Flushes the microtask queue (one event-loop turn). */
+export function nextTick() {
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+// ---------------------------------------------------------------------------
+// Common stub factories
+// ---------------------------------------------------------------------------
+
+/**
+ * Stubs `element.$.toast` with a spy-equipped object and returns it.
+ *
+ * @param {Object} element - Polymer element under test.
+ * @returns {{ text: string, open: sinon.SinonSpy }}
+ */
+export function stubToast(element) {
+ const toastStub = { text: '', open: sinon.spy() };
+ sinon.stub(element.$, 'toast').value(toastStub);
+ return toastStub;
+}
+
+// ---------------------------------------------------------------------------
+// Shared test-suite factories
+// ---------------------------------------------------------------------------
+
+/**
+ * Registers the canonical `_showError` test suite against an element getter.
+ *
+ * @param {Function} getElement - Returns the element under test (called inside each test).
+ */
+export function addShowErrorSuite(getElement) {
+ suite('_showError', () => {
+ teardown(() => sinon.restore());
+
+ test('sets toast text and opens it', () => {
+ const element = getElement();
+ const toastStub = stubToast(element);
+ element._showError('Something went wrong');
+ expect(toastStub.text).to.equal('Something went wrong');
+ expect(toastStub.open).to.have.been.calledOnce;
+ });
+ });
+}
+
+/**
+ * Registers canonical `_toggleInstall` test suite against an element getter.
+ *
+ * @param {Function} getElement - Returns the element under test.
+ */
+export function addToggleInstallSuite(getElement) {
+ suite('_toggleInstall', () => {
+ test('sets _installExpanded to true', () => {
+ const element = getElement();
+ const fakeEvent = { preventDefault: sinon.spy() };
+ element._toggleInstall(fakeEvent);
+ expect(element._installExpanded).to.be.true;
+ });
+
+ test('calls event.preventDefault()', () => {
+ const element = getElement();
+ const fakeEvent = { preventDefault: sinon.spy() };
+ element._toggleInstall(fakeEvent);
+ expect(fakeEvent.preventDefault).to.have.been.calledOnce;
+ });
+ });
+}
+
+/**
+ * Registers a test suite for the navigate-first `_go` method pattern.
+ *
+ * Covers: dialog opening, _installExpanded reset, and re-entry guard.
+ *
+ * Stubs the element's `_navigate` method (if it exists) to prevent actual navigation
+ * and ensure test isolation. If the element doesn't expose `_navigate`, the stub is skipped.
+ *
+ * @param {Function} getElement - Returns the element under test.
+ * @param {string} goMethod - Name of the go method (e.g. '_go' or '_download').
+ */
+export function addGoSuite(getElement, goMethod = '_go') {
+ suite(`${goMethod} — navigate-first pattern`, () => {
+ setup(() => {
+ const element = getElement();
+ element.$.dialog.toggle = element.$.dialog.toggle || function () {};
+ // Stub _navigate if it exists to prevent actual globalThis.location assignment.
+ // This ensures each test is isolated and not dependent on test execution order.
+ if (typeof element._navigate === 'function') {
+ sinon.stub(element, '_navigate');
+ }
+ });
+
+ teardown(() => sinon.restore());
+
+ test('opens the dialog', () => {
+ const element = getElement();
+ const dialogToggleStub = sinon.stub(element.$.dialog, 'toggle');
+ element[goMethod]();
+ expect(dialogToggleStub).to.have.been.calledOnce;
+ });
+
+ test('resets _installExpanded to false', () => {
+ const element = getElement();
+ sinon.stub(element.$.dialog, 'toggle');
+ element._installExpanded = true;
+ element[goMethod]();
+ expect(element._installExpanded).to.be.false;
+ });
+
+ test('does not re-open the dialog when it is already open', () => {
+ const element = getElement();
+ element.$.dialog.opened = true;
+ const dialogToggleStub = sinon.stub(element.$.dialog, 'toggle');
+ element[goMethod]();
+ expect(dialogToggleStub).to.not.have.been.called;
+ });
+ });
+}
diff --git a/addons/nuxeo-drive/test/nuxeo-drive-upload-button.test.js b/addons/nuxeo-drive/test/nuxeo-drive-upload-button.test.js
new file mode 100644
index 0000000000..85be719a26
--- /dev/null
+++ b/addons/nuxeo-drive/test/nuxeo-drive-upload-button.test.js
@@ -0,0 +1,165 @@
+/**
+@license
+©2023 Hyland Software, Inc. and its affiliates. All rights reserved.
+All Hyland product names are registered or unregistered trademarks of Hyland Software, Inc. or its affiliates.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+import { fixture, html } from '@nuxeo/testing-helpers';
+import '../elements/nuxeo-drive-upload-button.js';
+import { base64UrlSafeEncode } from '../elements/nuxeo-drive-utils.js';
+import {
+ setupI18n,
+ stubToast,
+ addShowErrorSuite,
+ addToggleInstallSuite,
+ addGoSuite,
+} from './nuxeo-drive-test-helpers.test.js';
+
+// Setup i18n keys used by the component
+setupI18n({
+ 'driveUploadButton.tooltip': 'Upload with Nuxeo Drive',
+ 'driveUpload.directTransfer.failed': 'An error occurred while trying to upload the document with Nuxeo Drive.',
+ 'driveUpload.serverUrlTooLong': 'Server URL is too long to encode.',
+ 'driveButton.dialog.heading': 'Nuxeo Drive',
+ 'driveButton.dialog.description': 'Use Nuxeo Drive to work with your documents directly from your desktop.',
+ 'command.close': 'Close',
+});
+
+suite('nuxeo-drive-upload-button', () => {
+ let element;
+
+ setup(async () => {
+ element = await fixture(html``);
+ element.document = { path: '/default-domain/workspaces/test-folder' };
+ });
+
+ // Shared suites
+ addShowErrorSuite(() => element);
+ addToggleInstallSuite(() => element);
+ addGoSuite(() => element);
+
+ // ---------------------------------------------------------------------------
+ // _isAvailable
+ // ---------------------------------------------------------------------------
+ suite('_isAvailable', () => {
+ test('returns false when doc is null', () => {
+ expect(element._isAvailable(null)).to.be.false;
+ });
+
+ test('returns false when doc is undefined', () => {
+ expect(element._isAvailable(undefined)).to.be.false;
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // _go — error path
+ // ---------------------------------------------------------------------------
+ suite('_go — error handling', () => {
+ teardown(() => sinon.restore());
+
+ test('shows error when _compressUploadUrl throws', () => {
+ const toastStub = stubToast(element);
+ sinon.stub(element, '_compressUploadUrl').throws(new Error('compression failed'));
+ element._go();
+ expect(toastStub.text).to.equal('compression failed');
+ expect(toastStub.open).to.have.been.calledOnce;
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // _compressUploadUrl / directTransferUrl
+ // ---------------------------------------------------------------------------
+ suite('_compressUploadUrl', () => {
+ test('returns a nxdrive://direct-transfer/ URL', () => {
+ const compressed = element._compressUploadUrl();
+ expect(compressed).to.match(/^nxdrive:\/\/direct-transfer\/[A-Za-z0-9_-]+$/);
+ });
+
+ test('compressed URL does not contain the raw document path', () => {
+ const compressed = element._compressUploadUrl();
+ expect(compressed).to.not.include('default-domain');
+ expect(compressed).to.not.include('workspaces');
+ });
+
+ test('different document paths produce different compressed URLs', () => {
+ element.document = { path: '/default-domain/workspaces/folder-a' };
+ const url1 = element._compressUploadUrl();
+ element.document = { path: '/default-domain/workspaces/folder-b' };
+ const url2 = element._compressUploadUrl();
+ expect(url1).to.not.equal(url2);
+ });
+
+ test('handles path with leading slash correctly', () => {
+ element.document = { path: '/default-domain/workspaces/my-folder' };
+ const url = element._compressUploadUrl();
+ expect(url).to.match(/^nxdrive:\/\/direct-transfer\/[A-Za-z0-9_-]+$/);
+ });
+
+ test('handles path without leading slash correctly', () => {
+ element.document = { path: 'default-domain/workspaces/my-folder' };
+ const url = element._compressUploadUrl();
+ expect(url).to.match(/^nxdrive:\/\/direct-transfer\/[A-Za-z0-9_-]+$/);
+ });
+
+ test('directTransferUrl getter delegates to _compressUploadUrl', () => {
+ const compressSpy = sinon.spy(element, '_compressUploadUrl');
+ const url = element.directTransferUrl;
+ expect(compressSpy).to.have.been.calledOnce;
+ expect(url).to.match(/^nxdrive:\/\/direct-transfer\/[A-Za-z0-9_-]+$/);
+ sinon.restore();
+ });
+
+ test('shows error and throws when server bytes exceed 255', () => {
+ const toastStub = stubToast(element);
+ const origEncode = TextEncoder.prototype.encode;
+ let callCount = 0;
+ sinon.stub(TextEncoder.prototype, 'encode').callsFake(function (str) {
+ callCount++;
+ if (callCount === 1) {
+ return new Uint8Array(256);
+ }
+ return origEncode.call(this, str);
+ });
+
+ element.document = { path: '/some/path' };
+ expect(() => element._compressUploadUrl()).to.throw();
+ expect(toastStub.open).to.have.been.calledOnce;
+
+ sinon.restore();
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // base64UrlSafeEncode
+ // ---------------------------------------------------------------------------
+ suite('base64UrlSafeEncode', () => {
+ test('output contains no standard base64 padding (=)', () => {
+ const bytes = new Uint8Array([1, 2, 3, 4, 5]);
+ const result = base64UrlSafeEncode(bytes);
+ expect(result).to.not.include('=');
+ });
+
+ test('output contains no + characters (URL-safe)', () => {
+ const bytes = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 200));
+ const result = base64UrlSafeEncode(bytes);
+ expect(result).to.not.include('+');
+ });
+
+ test('output contains no / characters (URL-safe)', () => {
+ const bytes = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 200));
+ const result = base64UrlSafeEncode(bytes);
+ expect(result).to.not.include('/');
+ });
+ });
+});
diff --git a/i18n/messages.json b/i18n/messages.json
index f53dcf0f83..327ba1db2d 100644
--- a/i18n/messages.json
+++ b/i18n/messages.json
@@ -506,6 +506,11 @@
"bulkDownload.filename.selection": "selection",
"bulkDownload.preparing": "Preparing Zip export.",
"bulkDownload.tooltip": "Download All as Zip",
+ "driveButton.dialog.description": "Use Nuxeo Drive to work with your documents directly from your desktop.",
+ "driveButton.dialog.heading": "Nuxeo Drive",
+ "driveButton.dialog.install.hint": "If Nuxeo Drive didn't open, it may not be installed on this computer.",
+ "driveButton.dialog.install.prompt": "Download and install Nuxeo Drive for your platform:",
+ "driveButton.install.dialog.hint": "Nuxeo Drive didn\u0027t open? Click here to download and install it.",
"driveDesktopPackages.install": "Package to Install",
"driveDesktopPackages.platform": "Platform",
"driveDownload.directTransfer.failed": "An error occurred while trying to download the document with Nuxeo Drive.",
@@ -514,9 +519,8 @@
"driveDownload.tooManyDocuments": "You have selected more documents than supported. Please select up to {0} documents to download via Nuxeo Drive.",
"driveDownloadButton.tooltip": "Download with Nuxeo Drive",
"driveEditButton.dialog.heading": "Download Nuxeo Drive Client",
+ "driveEditButton.directTransfer.failed": "An error occurred while trying to open the document with Nuxeo Drive.",
"driveEditButton.tooltip": "Open with Nuxeo Drive",
- "driveUpload.directTransfer.failed": "An error occurred while trying to upload the document with Nuxeo Drive.",
- "driveUploadButton.tooltip": "Upload with Nuxeo Drive",
"drivePage.heading": "Nuxeo Drive",
"drivePage.packages": "Packages",
"drivePage.roots": "Synchronization Roots",
@@ -528,6 +532,9 @@
"driveSyncRootsManagement.roots.empty": "You currently don't have any synchronization roots.",
"driveSyncToggleButton.sync": "Synchronize",
"driveSyncToggleButton.unsync": "Unsynchronize",
+ "driveUpload.directTransfer.failed": "An error occurred while trying to upload the document with Nuxeo Drive.",
+ "driveUpload.serverUrlTooLong": "The server URL is too long to generate a Nuxeo Drive upload link.",
+ "driveUploadButton.tooltip": "Upload with Nuxeo Drive",
"dropzone.abort": "Abort file upload",
"dropzone.add": "Upload main file",
"dropzone.invalid.error": "Invalid content",
@@ -1539,4 +1546,4 @@
"workflowAnalytics.averageTaskDurationPerUser.user": "User",
"workflowAnalytics.averageWorkflowDuration.heading": "Average Workflow Duration",
"workflowAnalytics.workflowInitiators.heading": "Workflow Initiators"
-}
+}
\ No newline at end of file