-
Notifications
You must be signed in to change notification settings - Fork 5
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
Changes from 3 commits
1d8e0ab
123d6b2
f8bfca2
7be25c1
11c661d
ce8c2cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'; | ||||||
|
||||||
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) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we really need a wrapper function here? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'); | ||||||
|
@@ -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, | ||||||
|
@@ -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') { | ||||||
|
@@ -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'); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I prefer shorter messages
Suggested change
|
||||||
} | ||||||
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); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Either here or in |
||||||
if (!renditions) { | ||||||
return maxRendition; | ||||||
} | ||||||
renditions | ||||||
.filter((rendition) => rendition.type === 'image/png') | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
throw new Error(`Unexpected status code ${binary.status} retrieving asset binary`); | ||||||
} | ||||||
|
||||||
const blob = await binary.blob(); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two things:
|
||||||
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'); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
(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'); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
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}`); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would proactively check that |
||||||
} 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'); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would make |
||||||
} | ||||||
|
||||||
/** | ||||||
* 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]); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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']) { | ||||||
|
@@ -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(); | ||||||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.