From 8ddc60802aa1e5b0bd60e20238bf659ed289f7b3 Mon Sep 17 00:00:00 2001 From: jvega190 Date: Fri, 14 Aug 2026 15:44:11 -0600 Subject: [PATCH 1/3] [8200] Update ImagePicker and VideoPicker to support external mediaUrl from datasources --- .../FormsEngine/controls/ImagePicker.tsx | 15 +++++-------- .../FormsEngine/controls/VideoPicker.tsx | 12 +++++------ .../FormsEngine/lib/controlHelpers.tsx | 8 ++++--- studio-ui/ui/app/src/utils/string.ts | 21 +++++++++++++++++++ 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx b/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx index 8652e4c7c..1d8b9de6b 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx +++ b/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx @@ -31,7 +31,7 @@ import { listItemIconClasses } from '@mui/material/ListItemIcon'; import Menu from '@mui/material/Menu'; import { useImageInfo } from '../../../hooks/useImageInfo'; import { svgIconClasses } from '@mui/material/SvgIcon'; -import { ensureSingleSlash } from '../../../utils/string'; +import { resolveMediaUrl } from '../../../utils/string'; import { useDispatch } from 'react-redux'; import Tooltip from '@mui/material/Tooltip'; import { downloadMedia, getImageRestrictionMessages, showImageCropDialog } from '../lib/controlHelpers'; @@ -68,9 +68,9 @@ export function ImagePicker(props: ImagePickerProps) { // endregion const value = nnou(valueProp) ? valueProp : (defaultValue ?? ''); - const { imageInfo, isFetchingDimensions, isFetchingMetadata, errorDimensions, errorMetadata } = useImageInfo( - value ? ensureSingleSlash(`${guestBase}${value}`) : '' - ); + const mediaUrl = value ? resolveMediaUrl(guestBase, value) : ''; + const { imageInfo, isFetchingDimensions, isFetchingMetadata, errorDimensions, errorMetadata } = + useImageInfo(mediaUrl); const hasValue = Boolean(value); const actions = dataSources?.actions ?? []; const dataSourcesLoading = dataSources?.status === 'loading'; @@ -150,12 +150,7 @@ export function ImagePicker(props: ImagePickerProps) { {hasValue ? ( - + diff --git a/studio-ui/ui/app/src/components/FormsEngine/controls/VideoPicker.tsx b/studio-ui/ui/app/src/components/FormsEngine/controls/VideoPicker.tsx index a9dea6299..be4bd08ea 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/controls/VideoPicker.tsx +++ b/studio-ui/ui/app/src/components/FormsEngine/controls/VideoPicker.tsx @@ -29,7 +29,7 @@ import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import { DeleteOutlined, DownloadOutlined, EditOutlined } from '@mui/icons-material'; import { svgIconClasses } from '@mui/material'; -import { ensureSingleSlash } from '../../../utils/string'; +import { resolveMediaUrl } from '../../../utils/string'; import useVideoInfo from '../../../hooks/useVideoInfo'; import Skeleton from '@mui/material/Skeleton'; import { downloadMedia } from '../lib/controlHelpers'; @@ -47,13 +47,11 @@ export interface VideoPickerProps extends ControlProps { export function VideoPicker(props: VideoPickerProps) { const { field, value, setValue, readonly: formReadonly, dataSources } = props; const { guestBase } = useEnv(); - // TODO: For testing, by using 3000 as the guestBase both the fetch in `useImageInfo` and the download functionality will work - // const guestBase = 'http://localhost:3000'; const hasValue = Boolean(value); + const mediaUrl = value ? resolveMediaUrl(guestBase, value) : ''; const { formatMessage } = useIntl(); - const { videoInfo, isFetchingMetadata, isFetchingDimensions, errorDimensions, errorMetadata } = useVideoInfo( - value ? ensureSingleSlash(`${guestBase}${value}`) : '' - ); + const { videoInfo, isFetchingMetadata, isFetchingDimensions, errorDimensions, errorMetadata } = + useVideoInfo(mediaUrl); const [anchorEl, setAnchorEl] = React.useState(null); const [addMenuOpen, setAddMenuOpen] = useState(false); @@ -99,7 +97,7 @@ export function VideoPicker(props: VideoPickerProps) { {hasValue ? ( - + diff --git a/studio-ui/ui/app/src/components/FormsEngine/lib/controlHelpers.tsx b/studio-ui/ui/app/src/components/FormsEngine/lib/controlHelpers.tsx index 350bd17e6..c52f80c64 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/lib/controlHelpers.tsx +++ b/studio-ui/ui/app/src/components/FormsEngine/lib/controlHelpers.tsx @@ -29,7 +29,7 @@ import ContentType from '../../../models/ContentType'; import FormsEngineField from '../components/FormsEngineField'; import { FormsEngineAtoms, ItemContext, ItemMetaContext, StableGlobalContext } from './formsEngineContext'; import { getFileNameFromPath } from '../../../utils/path'; -import { ensureSingleSlash } from '../../../utils/string'; +import { isExternalMediaUrl, resolveMediaUrl } from '../../../utils/string'; import { Dispatch as ReduxDispatch } from 'redux'; import { BrowseFilesDialogProps } from '../../BrowseFilesDialog'; import { nanoid } from 'nanoid'; @@ -208,7 +208,7 @@ export function renderFieldControl( * */ export function downloadMedia(base: string, url: string) { const link = document.createElement('a'); - link.href = ensureSingleSlash(`${base}${url}`); + link.href = resolveMediaUrl(base, url); link.download = getFileNameFromPath(url); // Extracts the file name from the URL document.body.appendChild(link); link.click(); @@ -362,6 +362,8 @@ export const showImageCropDialog = ({ onCrop: (blob: Blob, newPath?: string) => void; }): void => { const dialogId = nanoid(); + // Remote/absolute URLs load as `src` in the editor; writing cropped content back requires a site path. + const canWriteContent = writeContent !== false && !isExternalMediaUrl(path); dispatch( pushDialog({ id: dialogId, @@ -371,7 +373,7 @@ export const showImageCropDialog = ({ mimeType, subtitle: restrictions ? : undefined, restrictions, - writeContent, + writeContent: canWriteContent, onCrop: (blob: Blob, newPath: string) => { dispatch(popDialog({ id: dialogId })); onCrop?.(blob, newPath); diff --git a/studio-ui/ui/app/src/utils/string.ts b/studio-ui/ui/app/src/utils/string.ts index f563e5e6e..7a2e1c149 100644 --- a/studio-ui/ui/app/src/utils/string.ts +++ b/studio-ui/ui/app/src/utils/string.ts @@ -180,6 +180,27 @@ export function ensureSingleSlash(url: string): string { return /^(http|https):\/\//g.test(url) ? url.replace(/([^:]\/)\/+/g, '$1') : url.replace(/\/+/g, '/'); } +/** + * True when the value is already a loadable absolute/remote media URL (http(s), data, or blob). + * Site-relative paths like `/static-assets/...` return false. + */ +export function isExternalMediaUrl(url: string): boolean { + if (!url) return false; + return /^(https?:)?\/\//i.test(url) || url.startsWith('data:') || url.startsWith('blob:'); +} + +/** + * Resolves a media field value for preview / fetch / download. + * Absolute/remote URLs are returned as-is; site paths are prefixed with `guestBase` (FE1 image-picker parity). + */ +export function resolveMediaUrl(guestBase: string, value: string): string { + if (!value) return value; + if (isExternalMediaUrl(value)) { + return ensureSingleSlash(value); + } + return ensureSingleSlash(`${guestBase}${value}`); +} + export function getSimplifiedVersion(version: string, options: { minor?: boolean; patch?: boolean } = {}) { if (!version) { return version; From 9004b2f3db77248ffdd632bdecf811259e12acab Mon Sep 17 00:00:00 2001 From: jvega190 Date: Fri, 14 Aug 2026 15:56:23 -0600 Subject: [PATCH 2/3] Preserve external URL syntax. --- studio-ui/ui/app/src/utils/string.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/studio-ui/ui/app/src/utils/string.ts b/studio-ui/ui/app/src/utils/string.ts index 7a2e1c149..6be743610 100644 --- a/studio-ui/ui/app/src/utils/string.ts +++ b/studio-ui/ui/app/src/utils/string.ts @@ -186,7 +186,7 @@ export function ensureSingleSlash(url: string): string { */ export function isExternalMediaUrl(url: string): boolean { if (!url) return false; - return /^(https?:)?\/\//i.test(url) || url.startsWith('data:') || url.startsWith('blob:'); + return /^(https?:)?\/\//i.test(url) || /^(data|blob):/i.test(url); } /** @@ -196,7 +196,7 @@ export function isExternalMediaUrl(url: string): boolean { export function resolveMediaUrl(guestBase: string, value: string): string { if (!value) return value; if (isExternalMediaUrl(value)) { - return ensureSingleSlash(value); + return value; } return ensureSingleSlash(`${guestBase}${value}`); } From 3ac0b500b188de77351e2846c7fd488e065bcc79 Mon Sep 17 00:00:00 2001 From: jvega190 Date: Tue, 18 Aug 2026 16:46:42 -0600 Subject: [PATCH 3/3] Handle external url images not meeting restrictions --- .../FormsEngine/controls/ImagePicker.tsx | 44 +++++++++++++++---- studio-ui/ui/app/src/utils/content.ts | 4 +- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx b/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx index 1d8b9de6b..d0c159d5e 100644 --- a/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx +++ b/studio-ui/ui/app/src/components/FormsEngine/controls/ImagePicker.tsx @@ -31,16 +31,22 @@ import { listItemIconClasses } from '@mui/material/ListItemIcon'; import Menu from '@mui/material/Menu'; import { useImageInfo } from '../../../hooks/useImageInfo'; import { svgIconClasses } from '@mui/material/SvgIcon'; -import { resolveMediaUrl } from '../../../utils/string'; +import { isExternalMediaUrl, resolveMediaUrl } from '../../../utils/string'; import { useDispatch } from 'react-redux'; import Tooltip from '@mui/material/Tooltip'; -import { downloadMedia, getImageRestrictionMessages, showImageCropDialog } from '../lib/controlHelpers'; +import Alert from '@mui/material/Alert'; +import { + downloadMedia, + getImageRestrictionMessages, + ImageRestrictionSubtitle, + showImageCropDialog +} from '../lib/controlHelpers'; import type { ImageRestrictions } from '../../ImageEditorDialog/types'; import Skeleton from '@mui/material/Skeleton'; import { nnou, nou } from '../../../utils/object'; import { validateImageRestrictions } from '../../../utils/content'; import GroupedDataSourceActionMenuItems from '../components/GroupedDataSourceActionMenuItems'; -import type { DataSourceSelection } from '../dataSources/types'; +import type { DataSourceAssetSelection, DataSourceSelection } from '../dataSources/types'; import { showSystemNotification } from '../../../state/actions/system'; import { EmptyState } from '../../EmptyState'; @@ -78,6 +84,7 @@ export function ImagePicker(props: ImagePickerProps) { const actionsReady = Boolean(dataSources?.context) && actions.length > 0 && !dataSourcesLoading; const [anchorEl, setAnchorEl] = useState(null); const [addMenuOpen, setAddMenuOpen] = useState(false); + const [rejectedExternalUrl, setRejectedExternalUrl] = useState(null); useEffect(() => { // If there's a default value and no value has been set yet, set it as the value. @@ -96,9 +103,16 @@ export function ImagePicker(props: ImagePickerProps) { ? selected.path : undefined; if (!path) return; - validateImageRestrictions(path, restrictions) + setRejectedExternalUrl(null); + validateImageRestrictions(path, restrictions, (selected as DataSourceAssetSelection).mimeType) .then((meetsRestrictions) => { - if (!meetsRestrictions) { + if (meetsRestrictions) { + setValue(path); + } else if (isExternalMediaUrl(path)) { + // Cropping requires writing the result to a site path, which isn't possible for an external URL. The + // crop would be discarded and the field would keep the offending URL, so reject the selection instead. + setRejectedExternalUrl(path); + } else { showImageCropDialog({ dispatch, path, @@ -108,8 +122,6 @@ export function ImagePicker(props: ImagePickerProps) { writeContent: true, onCrop: (_blob: Blob, newPath: string) => setValue(newPath ?? path) }); - } else { - setValue(path); } }) .catch(() => { @@ -132,6 +144,7 @@ export function ImagePicker(props: ImagePickerProps) { ) : null; const handleRemoveImage = () => { + setRejectedExternalUrl(null); setValue(null); }; @@ -147,7 +160,22 @@ export function ImagePicker(props: ImagePickerProps) { > {actionMenuItems} - + + {rejectedExternalUrl && ( + setRejectedExternalUrl(null)} + > + {' '} + {' '} + + + )} {hasValue ? ( diff --git a/studio-ui/ui/app/src/utils/content.ts b/studio-ui/ui/app/src/utils/content.ts index da1af7cee..685945a67 100644 --- a/studio-ui/ui/app/src/utils/content.ts +++ b/studio-ui/ui/app/src/utils/content.ts @@ -1208,8 +1208,8 @@ function doesImageMeetSizeRestrictions(file: HTMLImageElement, restrictions?: Im * @param restrictions - Optional size restrictions to validate the image against. * @returns Promise that resolves to true if the image meets the restrictions or no restrictions are provided, false otherwise. * */ -export function validateImageRestrictions(path: string, restrictions?: ImageRestrictions): Promise { - if (!restrictions || (!isImage(path) && !isBlobUrl(path) && !path.startsWith('data:image/'))) { +export function validateImageRestrictions(path: string, restrictions?: ImageRestrictions, mimeType?: string): Promise { + if (!restrictions || (!(isImage(path) || mimeType?.startsWith('image/')) && !isBlobUrl(path) && !path.startsWith('data:image/'))) { return Promise.resolve(true); } return new Promise((resolve) => {