Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement rail style for selector #47

Merged
merged 6 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 286 additions & 37 deletions blocks/aem-asset-selector/aem-asset-selector-util.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,102 @@
/* eslint-disable no-console */
/*
Copyright 2023 Adobe. All rights reserved.
This file is licensed to you 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 REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/

/**
* @typedef Links
*/

/**
* @typedef Asset
* @property {Links} _links Rels for the asset. Is expected to have a
* http://ns.adobe.com/adobecloud/rel/rendition rel for retrieving the
* asset's renditions, and a http://ns.adobe.com/adobecloud/rel/download
* rel for retrieving a URL to the asset's original content, which
* doesn't require authentication.
*/

/**
* @typedef Rendition
* @property {string} type Content type of the rendition.
* @property {number} width Width, in pixels, of the rendition.
* @property {string} href Full URL to the rendition's binary. This URL
* will require authentication.
* @property {Links} _links Rels for the rendition. Is expected to have
* a http://ns.adobe.com/adobecloud/rel/download rel for retrieving a
* URL to the rendition's content, which doesn't require authentication.
*/

/**
* @typedef AssetSelectorConfig
* @property {string} [imsClientId] If provided, will be used as the client ID
* when authenticating with IMS.
* @property {string} [repositoryId] If provided, will be used as the selected
* repository in the Asset Selector.
* @property {string} [imsOrgId] If provided, will be used as the IMS org to use
* when logging in through IMS.
* @property {string} [environment] If provided, will be the IMS environment to
* use when logging in through IMS. Should be STAGE or PROD.
* @property {function} [onAssetSelected] If provided, will be invoked with the
* repository metadata for the asset that was selected.
* @property {function} [onAssetDeselected] If provided, will be invoked with
* no arguments if the currently selected asset is deselected.
* @property {function} [onAccessTokenReceived] If provided, will be invoked
* when an IMS token is available for the user. May be invoked multiple times
* with the same token during the session.
*/

const AS_MFE = 'https://experience.adobe.com/solutions/CQ-assets-selectors/static-assets/resources/assets-selectors.js';
const IMS_ENV_STAGE = 'stg1';
const IMS_ENV_PROD = 'prod';
const API_KEY = 'franklin';
const WEB_TOOLS = 'https://master-sacred-dove.ngrok-free.app';
const REL_DOWNLOAD = 'http://ns.adobe.com/adobecloud/rel/download';
const REL_RENDITIONS = 'http://ns.adobe.com/adobecloud/rel/rendition';
// TODO: change this to Asset Link IMS client ID
const IMS_CLIENT_ID = 'p66302-franklin';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely this should change to one of the pre-existing asset link client IDs.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we're planning on using one. I haven't changed it yet because we're waiting on a static host so that the client ID will work in production IMS.

Since we'll be defining a single block that everyone uses, we could potentially still require the client ID in the block config. That would at least keep the client ID out of a public github repository. It'll still end up in the rendered javascript, but at least it wouldn't be github that way.


let imsInstance = null;
let imsEnvironment = IMS_ENV_PROD;

/**
* Logs a message to the console.
* @param {...any} theArgs Arguments to pass to the console log
* statement.
*/
function logMessage(...theArgs) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need a wrapper function here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessarily, I just added it so that we don't have to add an eslint ignore rule every time we write to the console.

// eslint-disable-next-line no-console
console.log.apply(null, theArgs);
}

/**
* Retrieves the value of a rel from repository metadata.
* @param {Asset|Rendition} repositoryMetadata Metadata whose links
* will be used.
* @param {string} rel The rel to retrieve.
*/
function getRel(repositoryMetadata, rel) {
// eslint-disable-next-line no-underscore-dangle
return repositoryMetadata._links[rel];
Copy link

@tmathern tmathern Aug 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to check for null/undefined to be more defensive?

}

/**
* Adds a new <script> tag to the documents <head>.
* @param {string} url URL of the script to load.
* @param {function} callback Invoked after the script has
* finished loading.
* @param {string} [type] If provided, the value to use in
* the scripts "type" property. If unspecified the type
* will be left blank.
* @returns {HTMLElement} The newly created script tag.
*/
function loadScript(url, callback, type) {
const $head = document.querySelector('head');
const $script = document.createElement('script');
Expand All @@ -22,6 +109,12 @@ function loadScript(url, callback, type) {
return $script;
}

/**
* Loads the asset selector by registering the IMS auth service it
* should use to authorize users.
* @param {AssetSelectorConfig} cfg Configuration to use for the
* selector.
*/
function load(cfg) {
const imsProps = {
imsClientId: cfg['ims-client-id'] ? cfg['ims-client-id'] : IMS_CLIENT_ID,
Expand All @@ -37,6 +130,13 @@ function load(cfg) {
imsInstance = registeredTokenService;
}

/**
* Initializes the asset selector by loading its script file, and registering
* the IMS auth service it should use to authenticate with IMS.
* @param {AssetSelectorConfig} cfg Configuration for the selector.
* @param {function} [callback] If provided, will be invoked after
* all intialization steps are complete.
*/
export function init(cfg, callback) {
if (cfg.environment) {
if (cfg.environment.toUpperCase() === 'STAGE') {
Expand All @@ -55,62 +155,212 @@ export function init(cfg, callback) {
});
}

function onClose() {
document.getElementById('asset-selector-dialog').close();
}

/**
* Generates a URL that can be used to retrieve an asset's binary
* without authentication.
* @param {string} url URL to the asset in AEM.
* @returns {Promise} Resolves with the public asset URL.
*/
async function getAssetPublicUrl(url) {
const response = await fetch(`${WEB_TOOLS}/asset-bin?src=${url}`, {
headers: {
Authorization: `Bearer ${imsInstance.getImsToken()}`,
'x-api-key': API_KEY,
},
});
if (response && response.ok) {
const json = await response.json();
return json['asset-url'];
if (!response) {
throw new Error('Did not receive response from request');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer shorter messages

Suggested change
throw new Error('Did not receive response from request');
throw new Error('No response from request');

}
if (response && !response.ok) {
throw new Error(response.statusTest);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}: ${response.statusText}`);
}
return null;
const json = await response.json();
return json['asset-url'];
}

async function handleSelection(selection) {
const selectedAsset = selection[0];
/**
* Retrieves the rendition that should be copied into the clipboard for
* the given asset.
* @param {Asset} asset Asset whose copy rendition should be retrieved.
* @returns {Rendition} Rendition whose content should be copied to
* the clipboard. Will return a falsy value if no suitable rendition
* could be found.
*/
function getCopyRendition(asset) {
let maxRendition = null;
// eslint-disable-next-line no-underscore-dangle
selectedAsset._links['http://ns.adobe.com/adobecloud/rel/rendition'].forEach((rendition) => {
if ((!maxRendition || maxRendition.width < rendition.width)) {
maxRendition = rendition;
}
});
const renditions = getRel(asset, REL_RENDITIONS);
Copy link

@tmathern tmathern Aug 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either here or in getRel, we need to make sure asset is not null/undefined ("empty").

if (!renditions) {
return maxRendition;
}
renditions
.filter((rendition) => rendition.type === 'image/png')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the rendition.type === 'image/png' check necessary? With it, we'd be omitting the better resolution renditions. I recollect having added it since clipboard api did not support other mime types. Do we still have that limitation?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clipboard API only supports PNGs as blobs, so I added that for expedience. I'll add a TODO that we should revisit this and create a follow-up tickiet.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, I think the best png rendition we have is 319px which may become quite blurry on rendering. I thought the signed url from RAPI does not require auth headers but apparently it does need. One thing worth trying could be local object urls.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't Franklin convert the images anyway into webp (or other formats/sizes depending on the js)? SO having PNG at least should be fine to start with an MVP.

Copy link
Collaborator

@sdmcraft sdmcraft Aug 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't Franklin convert the images anyway into webp

Yes it would but what I am not sure is whether it'd improve the image in anyway if it input image is a 319px png rendition. I agree that we can park it and tackle in a separate issue but overall seems to be an important requirement for real customer use cases.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that this needs to be improved. I put it in there as a "placeholder" without doing a great job communicating that it needs to be improved :)

I've created #48 as a follow-up to this.

.forEach((rendition) => {
if ((!maxRendition || maxRendition.width < rendition.width)) {
maxRendition = rendition;
}
});
return maxRendition;
}

const assetPublicUrl = await getAssetPublicUrl(maxRendition.href.substring(0, maxRendition.href.indexOf('?')));
console.log('Asset public URL:', assetPublicUrl);
/**
* Uses the navigator global object to write a clipboard item to the clipboard.
* The clipboard item's content will be an <img /> tag with the given URL as
* its src property.
* @param {string} assetPublicUrl URL to use as the image's src.
* @returns {Promise} Resolves when the item has been written to the clipboard.
*/
async function copyToClipboardWithHtml(assetPublicUrl) {
const assetMarkup = `<img src="${assetPublicUrl}" />`;

const data = [
// eslint-disable-next-line no-undef
new ClipboardItem({ 'text/html': new Blob([assetMarkup], { type: 'text/html' }) }),
];
// Write the new clipboard contents
await navigator.clipboard.write(data);
// onClose();
return navigator.clipboard.write(data);
}

// eslint-disable-next-line no-unused-vars
function handleNavigateToAsset(asset) {
// onClose();
/**
* Uses the navigator global object to write a clipboard item to the clipboard.
* The clipboard item's content will be a Blob containing the image binary from
* the given URL, which the method will retrieve.
* @param {string} assetPublicUrl URL of the image to retrieve.
* @param {string} mimeType Content type of the image being retrieved.
* @returns {Promise} Resolves when the item has been written to the clipboard.
*/
async function copyToClipboardWithBinary(assetPublicUrl, mimeType) {
const binary = await fetch(assetPublicUrl);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we trust the URL to be properly formatted? Or should we verify it (so we can add nice error handling).


if (!binary.ok) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!binary.ok) {
if (!binary || !binary.ok) {

throw new Error(`Unexpected status code ${binary.status} retrieving asset binary`);
}

const blob = await binary.blob();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things:

  • I would add a try/catch statement here to handle errors,
  • I would verify the blob is not null/undefined ("empty").

const clipboardOptions = {};
clipboardOptions[mimeType] = blob;
const data = [
new ClipboardItem(clipboardOptions),
];
return navigator.clipboard.write(data);
}

/**
* Copies the given asset to the clipboard, without using the Repository API,
* and therefore avoiding calls directly to AEM. The primary case for using
* this method would be if AEM's CORS policies would deny requests from the
* asset selector.
* @param {Asset} asset Asset repository metadata, as provided by the asset
* selector.
* @returns {Promise<boolean>} Resolves with true if the asset was
* copied successfully, otherwise resolves with false.
*/
export async function copyAssetWithoutRapi(asset) {
const maxRendition = getCopyRendition(asset);
if (!maxRendition) {
logMessage('No suitable rendition to copy found');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
logMessage('No suitable rendition to copy found');
logMessage('No rendition to copy found');

(Otherwise, if I see this error message, I would ask what a suitable rendition is).

return false;
}
try {
const assetPublicUrl = await getAssetPublicUrl(maxRendition.href.substring(0, maxRendition.href.indexOf('?')));
if (!assetPublicUrl) {
logMessage('Unable to generate public url for copy');
return false;
}
await copyToClipboardWithHtml(assetPublicUrl);
} catch (e) {
logMessage('error copying asset to clipboard', e);
tmathern marked this conversation as resolved.
Show resolved Hide resolved
return false;
}
return true;
}

/**
* Copies the given asset to the clipboard, using the Repository API to
* generate a download URL for the asset, then using the generated URL
* to copy the asset's content to the clipboard.
* @param {Asset} asset Asset repository metadata, as provided by the
* asset selector.
* @returns {Promise<boolean>} Resolves with true if the asset was
* copied successfully, otherwise resolves with false.
*/
export async function copyAssetWithRapi(asset) {
// eslint-disable-next-line no-underscore-dangle
if (!asset) {
logMessage('Asset metadata does not contain sufficient information');
return false;
}
const rendition = getCopyRendition(asset);
if (!rendition) {
logMessage('No suitable rendition to copy found');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
logMessage('No suitable rendition to copy found');
logMessage('No rendition to copy found');

return false;
}
const download = getRel(rendition, REL_DOWNLOAD);
if (!download || !download.href) {
logMessage('Rendition does not contain sufficient information');
return false;
}
try {
const url = download.href;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${imsInstance.getImsToken()}`,
},
});
if (!res.ok) {
logMessage(`Download request for rendition binary returned unexpected status code ${res.status}: ${res.statusText}`);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
logMessage(`Download request for rendition binary returned unexpected status code ${res.status}: ${res.statusText}`);
logMessage(`Download request for rendition binary failed with status code ${res.status}: ${res.statusText}`);

If it's unexpected, it means it failed for our use case, right?

return false;
}
const downloadJson = await res.json();
await copyToClipboardWithBinary(downloadJson.href, downloadJson.type);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would proactively check that downloadJson is not null/undefined ("empty"), instead of possbly having it throw at the next line.

} catch (e) {
logMessage('error copying asset to clipboard', e);
return false;
}

return true;
}

/**
* Retrieves the asset selector's containing element.
* @returns {HTMLElement} The asset selector.
*/
function getAssetSelector() {
return document.getElementById('asset-selector');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would make 'asset-selector' a constant on top an export it. I have a feeling this will be reused.

}

/**
* Invoked when the currently selected asset in the asset selector has
* changed. Will invoke either onAssetSelected() or onAssetDeselected()
* depending on the new selection state.
* @param {Array<Asset>} selection The new selection in the selector.
* @param {AssetSelectorConfig} cfg Configuration for the asset selector.
*/
function handleAssetSelection(selection, cfg) {
if (cfg) {
if (selection.length && cfg.onAssetSelected) {
cfg.onAssetSelected(selection[0]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If length > 1, can we log that we use only the first one? Or at least explain it in the readme.

} else if (!selection.length && cfg.onAssetDeselected) {
cfg.onAssetDeselected();
}
}
}

/**
* Renders the asset selector according to a given configuration. The
* selector will use its IMS flow to ensure that the user has logged
* in.
* @param {AssetSelectorConfig} cfg Configuration to use for the
* asset selector.
* @returns {Promise} Resolves when the IMS flow has been initiated.
*/
export async function renderAssetSelectorWithImsFlow(cfg) {
const assetSelectorProps = {
onClose,
handleSelection,
handleNavigateToAsset,
handleAssetSelection: (e) => handleAssetSelection(e, cfg),
env: cfg.environment ? cfg.environment.toUpperCase() : 'PROD',
apiKey: API_KEY,
hideTreeNav: true,
runningInUnifiedShell: false,
noWrap: true,
};

if (cfg['repository-id']) {
Expand All @@ -119,16 +369,15 @@ export async function renderAssetSelectorWithImsFlow(cfg) {
if (cfg['ims-org-id']) {
assetSelectorProps.imsOrg = cfg['ims-org-id'];
}
const container = document.getElementById('asset-selector');

// eslint-disable-next-line no-undef
PureJSSelectors.renderAssetSelectorWithAuthFlow(container, assetSelectorProps, () => {
const assetSelectorDialog = document.getElementById('asset-selector-dialog');
assetSelectorDialog.showModal();
});
PureJSSelectors.renderAssetSelectorWithAuthFlow(getAssetSelector(), assetSelectorProps);
}

/**
* Does the work of logging out of IMS.
* @returns {Promise} Resolves when logout has finished.
*/
export async function logoutImsFlow() {
// eslint-disable-next-line no-console
console.log('Signing out...', imsInstance);
await imsInstance.signOut();
return imsInstance.signOut();
}
Loading