From 4c7cafffdb0469a1358efd35156cdb8071ec4cef Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Tue, 19 Nov 2019 13:42:02 +0100 Subject: [PATCH] [form-builder] Asset Source Plugin support (#1591) * [form-builder] Remove upload from image input * [form-builder] Image input: Browse sources defined by part 'form-builder/input/asset-source' * [form-builder] Let asset sources be configured on type or via global part * [form-builder] Remove asset source browser and use button dropdown instead * [form-builder] Adjust default source to show in dialog * [form-builder] Add @sanity/uuid * Rename parts * [form-builder] Add default config for form builder package * [form-builder] Rename parts * [form-builder] Tweak default asset source to fit plugin conventions * [form-builder] Image field utils * [form-builder] Re-introduce uploads in image field and deal with assets given from source plugins * [form-builder] Organize and Image input code better * [example-studio] Add default formbuilder config file * [test-studio] Add default formbuilder config file * [form-builder] Only use upload props when upload component * [form-builder] Array input must respect direct uploads settings * [form-builder] Remove console.log * [form-builder] Tweak icon alignments * [form-builder] Change button copy + title of default asset source * [form-builder] Uploads should support label * [form-builder] Make image input pass through label from asset source plugin * [form-builder] Rename option: originalFilename > filename * [client] Support source and sourceId in asset client * [form-builder] Image asset to support source and sourceId * [base] Update image asset schema to support source and sourceId * [base] Update base schema to know about assetSourceData * [client] Asset client can take title, description, and source * [form-builder] Update upload props for sanityImageAsset * [base] Rename assetSource > assetSourceData --- .../@sanity/base/src/schema/createSchema.js | 2 + .../base/src/schema/types/assetSourceData.js | 26 + .../base/src/schema/types/fileAsset.js | 17 + .../base/src/schema/types/imageAsset.js | 17 + .../@sanity/client/src/assets/assetsClient.js | 28 +- .../@sanity/form-builder/config.dist.json | 5 + packages/@sanity/form-builder/package.json | 1 + packages/@sanity/form-builder/sanity.json | 5 + .../form-builder/src/@types/parts.d.ts | 4 + .../src/inputs/ArrayInput/ArrayInput.tsx | 14 +- .../src/inputs/ImageInput/Asset.tsx | 7 +- .../{SelectAsset.tsx => DefaultSource.tsx} | 71 ++- .../src/inputs/ImageInput/ImageInput.tsx | 446 ++++++++++++------ .../src/inputs/ImageInput/styles/Asset.css | 4 + .../inputs/ImageInput/styles/ImageInput.css | 16 + .../src/inputs/ImageInput/utils/image.ts | 48 ++ .../sanity/inputs/client-adapters/assets.ts | 35 +- .../src/sanity/uploads/typedefs.ts | 13 +- packages/example-studio/config/.checksums | 3 +- .../config/@sanity/form-builder.json | 5 + packages/test-studio/config/.checksums | 3 +- .../config/@sanity/form-builder.json | 5 + 22 files changed, 583 insertions(+), 192 deletions(-) create mode 100644 packages/@sanity/base/src/schema/types/assetSourceData.js create mode 100644 packages/@sanity/form-builder/config.dist.json rename packages/@sanity/form-builder/src/inputs/ImageInput/{SelectAsset.tsx => DefaultSource.tsx} (54%) create mode 100644 packages/@sanity/form-builder/src/inputs/ImageInput/utils/image.ts create mode 100644 packages/example-studio/config/@sanity/form-builder.json create mode 100644 packages/test-studio/config/@sanity/form-builder.json diff --git a/packages/@sanity/base/src/schema/createSchema.js b/packages/@sanity/base/src/schema/createSchema.js index b25278ef472..00cf67cdcc5 100644 --- a/packages/@sanity/base/src/schema/createSchema.js +++ b/packages/@sanity/base/src/schema/createSchema.js @@ -7,6 +7,7 @@ import slug from './types/slug' import geopoint from './types/geopoint' import imageCrop from './types/imageCrop' import imageHotspot from './types/imageHotspot' +import assetSourceData from './types/assetSourceData' import imageAsset from './types/imageAsset' import imagePalette from './types/imagePalette' import imagePaletteSwatch from './types/imagePaletteSwatch' @@ -26,6 +27,7 @@ module.exports = schemaDef => { if (!hasErrors) { types = [ ...schemaDef.types, + assetSourceData, slug, geopoint, legacyRichDate, diff --git a/packages/@sanity/base/src/schema/types/assetSourceData.js b/packages/@sanity/base/src/schema/types/assetSourceData.js new file mode 100644 index 00000000000..7f8ee9707fd --- /dev/null +++ b/packages/@sanity/base/src/schema/types/assetSourceData.js @@ -0,0 +1,26 @@ +export default { + name: 'sanity.assetSourceData', + title: 'Asset Source Data', + type: 'object', + fields: [ + { + name: 'name', + title: 'Source name', + description: 'A canonical name for the source this asset is originating from', + type: 'string' + }, + { + name: 'id', + title: 'Asset Source ID', + description: + 'The unique ID for the asset within the originating source so you can programatically find back to it', + type: 'string' + }, + { + name: 'url', + title: 'Asset information URL', + description: 'A URL to find more information about this asset in the originating source', + type: 'string' + } + ] +} diff --git a/packages/@sanity/base/src/schema/types/fileAsset.js b/packages/@sanity/base/src/schema/types/fileAsset.js index 1b80082a2ee..42b1b07e338 100644 --- a/packages/@sanity/base/src/schema/types/fileAsset.js +++ b/packages/@sanity/base/src/schema/types/fileAsset.js @@ -21,6 +21,16 @@ export default { type: 'string', title: 'Label' }, + { + name: 'title', + type: 'string', + title: 'Title' + }, + { + name: 'description', + type: 'string', + title: 'Description' + }, { name: 'sha1hash', type: 'string', @@ -69,6 +79,13 @@ export default { title: 'Url', readOnly: true, fieldset: 'system' + }, + { + name: 'source', + type: 'sanity.assetSourceData', + title: 'Source', + readOnly: true, + fieldset: 'system' } ], preview: { diff --git a/packages/@sanity/base/src/schema/types/imageAsset.js b/packages/@sanity/base/src/schema/types/imageAsset.js index d88cd6311c9..8c53fe8ab72 100644 --- a/packages/@sanity/base/src/schema/types/imageAsset.js +++ b/packages/@sanity/base/src/schema/types/imageAsset.js @@ -21,6 +21,16 @@ export default { type: 'string', title: 'Label' }, + { + name: 'title', + type: 'string', + title: 'Title' + }, + { + name: 'description', + type: 'string', + title: 'Description' + }, { name: 'sha1hash', type: 'string', @@ -74,6 +84,13 @@ export default { name: 'metadata', type: 'sanity.imageMetadata', title: 'Metadata' + }, + { + name: 'source', + type: 'sanity.assetSourceData', + title: 'Source', + readOnly: true, + fieldset: 'system' } ], preview: { diff --git a/packages/@sanity/client/src/assets/assetsClient.js b/packages/@sanity/client/src/assets/assetsClient.js index 03b4fad7f35..6c3b3c5501e 100644 --- a/packages/@sanity/client/src/assets/assetsClient.js +++ b/packages/@sanity/client/src/assets/assetsClient.js @@ -51,6 +51,17 @@ assign(AssetsClient.prototype, { * @param {String} opts.contentType Mime type of the file * @param {Array} opts.extract Array of metadata parts to extract from image. * Possible values: `location`, `exif`, `image`, `palette` + * @param {String} opts.label Label + * @param {String} opts.title Title + * @param {String} opts.description Description + * @param {String} opts.creditLine The credit to person(s) and/or organisation(s) required by the supplier of the image to be used when published + * @param {Object} opts.source Source data (when the asset is from an external service) + * @param {String} opts.source.id The (u)id of the asset within the source, i.e. 'i-f323r1E' + * Required if source is defined + * @param {String} opts.source.name The name of the source, i.e. 'unsplash' + * Required if source is defined + * @param {String} opts.source.url A url to where to find the asset, or get more info about it in the source + * Optional * @return {Promise} Resolves with the created asset document */ upload(assetType, body, opts = {}) { @@ -65,9 +76,20 @@ assign(AssetsClient.prototype, { const dataset = validators.hasDataset(this.client.clientConfig) const assetEndpoint = assetType === 'image' ? 'images' : 'files' const options = optionsFromFile(opts, body) - const {label, filename} = options - const query = {label, filename, meta} - + const {label, title, description, creditLine, filename, source} = options + const query = { + label, + title, + description, + filename, + meta, + creditLine + } + if (source) { + query.sourceId = source.id + query.sourceName = source.name + query.sourceUrl = source.url + } const observable = this.client._requestObservable({ method: 'POST', timeout: options.timeout || 0, diff --git a/packages/@sanity/form-builder/config.dist.json b/packages/@sanity/form-builder/config.dist.json new file mode 100644 index 00000000000..b97a6a6d9d7 --- /dev/null +++ b/packages/@sanity/form-builder/config.dist.json @@ -0,0 +1,5 @@ +{ + "images": { + "directUploads": true + } +} diff --git a/packages/@sanity/form-builder/package.json b/packages/@sanity/form-builder/package.json index 32e4d47cf05..f0e4581a84b 100644 --- a/packages/@sanity/form-builder/package.json +++ b/packages/@sanity/form-builder/package.json @@ -30,6 +30,7 @@ "@sanity/mutator": "0.145.0", "@sanity/schema": "0.145.0", "@sanity/util": "0.145.0", + "@sanity/uuid": "0.145.0", "attr-accept": "^1.1.0", "classnames": "^2.2.5", "date-fns": "^1.29.0", diff --git a/packages/@sanity/form-builder/sanity.json b/packages/@sanity/form-builder/sanity.json index bed754ef5e7..8d9f37aa733 100644 --- a/packages/@sanity/form-builder/sanity.json +++ b/packages/@sanity/form-builder/sanity.json @@ -122,6 +122,11 @@ "name": "part:@sanity/form-builder/input/block-editor/block-markers-custom-default", "implements": "part:@sanity/form-builder/input/block-editor/block-markers-custom", "path": "inputs/BlockEditor/nodes/CustomMarkers.js" + }, + { + "name": "part:@sanity/form-builder/input/image/asset-source-default", + "implements": "part:@sanity/form-builder/input/image/asset-source", + "path": "inputs/ImageInput/DefaultSource.js" } ] } diff --git a/packages/@sanity/form-builder/src/@types/parts.d.ts b/packages/@sanity/form-builder/src/@types/parts.d.ts index be804fbb1e7..36b09d5e60d 100644 --- a/packages/@sanity/form-builder/src/@types/parts.d.ts +++ b/packages/@sanity/form-builder/src/@types/parts.d.ts @@ -33,6 +33,10 @@ declare module 'part:@sanity/components/textareas/*' { export default SanityTextareaComponent } + +declare module 'config:@sanity/form-builder' +declare module 'all:part:@sanity/form-builder/input/image/asset-source' + declare module 'part:@sanity/components/utilities/portal' declare module 'part:@sanity/components/lists/*' declare module 'part:@sanity/*' diff --git a/packages/@sanity/form-builder/src/inputs/ArrayInput/ArrayInput.tsx b/packages/@sanity/form-builder/src/inputs/ArrayInput/ArrayInput.tsx index 47b83ec6d4f..0f97dfa41c1 100644 --- a/packages/@sanity/form-builder/src/inputs/ArrayInput/ArrayInput.tsx +++ b/packages/@sanity/form-builder/src/inputs/ArrayInput/ArrayInput.tsx @@ -1,7 +1,7 @@ import React from 'react' import ArrayFunctions from 'part:@sanity/form-builder/input/array/functions' import {map} from 'rxjs/operators' -import {isPlainObject} from 'lodash' +import {isPlainObject, get} from 'lodash' import {ResolvedUploader, Uploader} from '../../sanity/uploads/typedefs' import {Marker, Type} from '../../typedefs' import {Path} from '../../typedefs/path' @@ -18,6 +18,9 @@ import randomKey from './randomKey' import Button from 'part:@sanity/components/buttons/default' import Fieldset from 'part:@sanity/components/fieldsets/default' import Details from '../common/Details' +import formBuilderConfig from 'config:@sanity/form-builder' + +const SUPPORT_DIRECT_UPLOADS = get(formBuilderConfig, 'images.directUploads') function createProtoValue(type: Type): ItemValue { if (type.jsonType !== 'object') { @@ -317,19 +320,20 @@ export default class ArrayInput extends React.Component ) } + const FieldSetComponent = SUPPORT_DIRECT_UPLOADS ? UploadTargetFieldset : Fieldset + const uploadProps = SUPPORT_DIRECT_UPLOADS ? {getUploadOptions: this.getUploadOptions, onUpload: this.handleUpload} : {} return ( - {value && value.length > 0 && this.renderList()} onCreateValue={createProtoValue} onChange={onChange} /> - + ) } } diff --git a/packages/@sanity/form-builder/src/inputs/ImageInput/Asset.tsx b/packages/@sanity/form-builder/src/inputs/ImageInput/Asset.tsx index ad5f439a2d6..a7a04d5d090 100644 --- a/packages/@sanity/form-builder/src/inputs/ImageInput/Asset.tsx +++ b/packages/@sanity/form-builder/src/inputs/ImageInput/Asset.tsx @@ -27,6 +27,7 @@ type AssetProps = { referenceCount?: number url?: string } + isSelected: boolean onClick?: (...args: any[]) => any onKeyPress?: (...args: any[]) => any onDeleteFinished: (...args: any[]) => any @@ -97,7 +98,7 @@ export default class Asset extends React.PureComponent { return [DIALOG_CLOSE_ACTION] } render() { - const {asset, onClick, onKeyPress} = this.props + const {asset, onClick, onKeyPress, isSelected} = this.props const {isDeleting, dialogType} = this.state const size = 75 const dpi = @@ -122,7 +123,7 @@ export default class Asset extends React.PureComponent { ] return ( { item.name !== 'delete') : menuItems} renderItem={this.renderMenuItem} onAction={this.handleMenuAction} > diff --git a/packages/@sanity/form-builder/src/inputs/ImageInput/SelectAsset.tsx b/packages/@sanity/form-builder/src/inputs/ImageInput/DefaultSource.tsx similarity index 54% rename from packages/@sanity/form-builder/src/inputs/ImageInput/SelectAsset.tsx rename to packages/@sanity/form-builder/src/inputs/ImageInput/DefaultSource.tsx index 6fa8c5ae379..e79702159a0 100644 --- a/packages/@sanity/form-builder/src/inputs/ImageInput/SelectAsset.tsx +++ b/packages/@sanity/form-builder/src/inputs/ImageInput/DefaultSource.tsx @@ -1,15 +1,22 @@ import React from 'react' +import ImageIcon from 'react-icons/lib/md/image' import client from 'part:@sanity/base/client' import Button from 'part:@sanity/components/buttons/default' +import Dialog from 'part:@sanity/components/dialogs/fullscreen' import styles from './styles/SelectAsset.css' import AssetWidget from './Asset' +import {AssetFromSource} from './ImageInput' + const PER_PAGE = 200 type Asset = { _id: string url: string } type Props = { - onSelect: (arg0: Asset) => void + onSelect: (arg0: AssetFromSource[]) => void + onClose: () => void + selectedAssets: Asset[] + selectionType: boolean } function createQuery(start = 0, end = PER_PAGE) { return ` @@ -26,7 +33,7 @@ type State = { isLoading: boolean } -export default class SelectAsset extends React.Component { +class DefaultSource extends React.Component { state = { assets: [], isLastPage: false, @@ -56,7 +63,7 @@ export default class SelectAsset extends React.Component { select(id) { const selected = this.state.assets.find(doc => doc._id === id) if (selected) { - this.props.onSelect(selected) + this.props.onSelect([{kind: 'assetDocumentId', value: id}]) } } handleItemClick = (event: React.SyntheticEvent) => { @@ -69,35 +76,51 @@ export default class SelectAsset extends React.Component { this.select(event.currentTarget.getAttribute('data-id')) } } + handleClose = () => { + if (this.props.onClose) { + this.props.onClose() + } + } handleFetchNextPage = () => { this.fetchPage(++this.pageNo) } render() { + const {selectedAssets} = this.props const {assets, isLastPage, isLoading} = this.state return ( -
-
- {assets.map(asset => ( - - ))} -
- {!isLoading && assets.length === 0 && ( -
No images found
- )} -
- {!isLastPage && ( - + +
+
+ {assets.map(asset => ( + selected._id === asset._id)} + onClick={this.handleItemClick} + onKeyPress={this.handleItemKeyPress} + onDeleteFinished={this.handleDeleteFinished} + /> + ))} +
+ {!isLoading && assets.length === 0 && ( +
No images found
)} +
+ {!isLastPage && ( + + )} +
-
+ ) } } + +export default { + name: 'sanity-default', + title: 'Uploaded images', + component: DefaultSource, + icon: ImageIcon +} diff --git a/packages/@sanity/form-builder/src/inputs/ImageInput/ImageInput.tsx b/packages/@sanity/form-builder/src/inputs/ImageInput/ImageInput.tsx index 718ea9db683..7eb87a03c44 100644 --- a/packages/@sanity/form-builder/src/inputs/ImageInput/ImageInput.tsx +++ b/packages/@sanity/form-builder/src/inputs/ImageInput/ImageInput.tsx @@ -1,37 +1,67 @@ -/* eslint-disable complexity */ - +// Modules +import {get, partition} from 'lodash' +import {Observable} from 'rxjs' +import HotspotImage from '@sanity/imagetool/HotspotImage' +import ImageIcon from 'react-icons/lib/md/image' +import ImageTool from '@sanity/imagetool' import React from 'react' + +// Parts +import assetSources from 'all:part:@sanity/form-builder/input/image/asset-source' import Button from 'part:@sanity/components/buttons/default' +import ButtonGrid from 'part:@sanity/components/buttons/button-grid' +import Dialog from 'part:@sanity/components/dialogs/fullscreen' +import DropDownButton from 'part:@sanity/components/buttons/dropdown' +import EditIcon from 'part:@sanity/base/edit-icon' +import Fieldset from 'part:@sanity/components/fieldsets/default' import FileInputButton from 'part:@sanity/components/fileinput/button' +import formBuilderConfig from 'config:@sanity/form-builder' import ProgressCircle from 'part:@sanity/components/progress/circle' -import EditIcon from 'part:@sanity/base/edit-icon' import UploadIcon from 'part:@sanity/base/upload-icon' +import userDefinedAssetSources from 'part:@sanity/form-builder/input/image/asset-sources?' import VisibilityIcon from 'part:@sanity/base/visibility-icon' -import {get, partition} from 'lodash' -import PatchEvent, {set, setIfMissing, unset} from '../../PatchEvent' -import styles from './styles/ImageInput.css' -import Dialog from 'part:@sanity/components/dialogs/fullscreen' -import ButtonGrid from 'part:@sanity/components/buttons/button-grid' + +// Package files +import {FormBuilderInput} from '../../FormBuilderInput' import {Marker, Reference, Type} from '../../typedefs' +import {Path} from '../../typedefs/path' import {ResolvedUploader, Uploader, UploaderResolver} from '../../sanity/uploads/typedefs' -import WithMaterializedReference from '../../utils/WithMaterializedReference' +import {urlToFile, base64ToFile} from './utils/image' import ImageToolInput from '../ImageToolInput' -import HotspotImage from '@sanity/imagetool/HotspotImage' -import SelectAsset from './SelectAsset' -import {FormBuilderInput} from '../../FormBuilderInput' +import PatchEvent, {set, setIfMissing, unset} from '../../PatchEvent' +import Snackbar from 'part:@sanity/components/snackbar/default' +import styles from './styles/ImageInput.css' import UploadPlaceholder from '../common/UploadPlaceholder' import UploadTargetFieldset from '../../utils/UploadTargetFieldset' -import Snackbar from 'part:@sanity/components/snackbar/default' -import ImageTool from '@sanity/imagetool' -import {Observable} from 'rxjs' -import {Path} from '../../typedefs/path' +import WithMaterializedReference from '../../utils/WithMaterializedReference' + +const SUPPORT_DIRECT_UPLOADS = get(formBuilderConfig, 'images.directUploads') type FieldT = { name: string type: Type } -interface Value { +export type AssetDocumentProps = { + originalFilename?: string + label?: string + title?: string + description?: string + creditLine?: string + source?: { + id: string + name: string + url?: string + } +} + +export type AssetFromSource = { + kind: 'assetDocumentId' | 'file' | 'base64' | 'url' + value: string | File + assetDocumentProps?: AssetDocumentProps +} + +export interface Value { _upload?: any asset?: Reference hotspot?: any @@ -63,9 +93,11 @@ type ImageInputState = { isUploading: boolean uploadError: Error | null isAdvancedEditOpen: boolean - isSelectAssetOpen: boolean + selectedAssetSource?: any hasFocus: boolean } +const globalAssetSources = userDefinedAssetSources ? userDefinedAssetSources : assetSources + export default class ImageInput extends React.PureComponent { _focusArea: any uploadSubscription: any @@ -73,11 +105,41 @@ export default class ImageInput extends React.PureComponent) => { - this.props.onChange(PatchEvent.from(unset(['asset']))) + assetSources = globalAssetSources + + constructor(props: Props) { + super(props) + // Allow overriding sources set directly on type.options + const sourcesFromType = get(props.type, 'options.sources') + if (Array.isArray(sourcesFromType) && sourcesFromType.length > 0) { + this.assetSources = sourcesFromType + } else if (sourcesFromType) { + this.assetSources = null + } + } + + focus() { + if (this._focusArea) { + this._focusArea.focus() + } + } + + setFocusArea = (el: any | null) => { + this._focusArea = el + } + + isImageToolEnabled() { + return get(this.props.type, 'options.hotspot') === true + } + + getConstrainedImageSrc = (assetDocument: Record): string => { + const materializedSize = ImageTool.maxWidth || 1000 + const maxSize = materializedSize * getDevicePixelRatio() + const constrainedSrc = `${assetDocument.url}?w=${maxSize}&h=${maxSize}&fit=max` + return constrainedSrc } clearUploadStatus() { @@ -91,11 +153,10 @@ export default class ImageInput extends React.PureComponent { - this.cancelUpload() - } - handleSelectFile = (files: FileList) => { - this.uploadFirstAccepted(files) + getUploadOptions = (file: File): Array => { + const {type, resolveUploader} = this.props + const uploader = resolveUploader && resolveUploader(type, file) + return uploader ? [{type: type, uploader}] : [] } uploadFirstAccepted(fileList: FileList) { @@ -117,11 +178,21 @@ export default class ImageInput extends React.PureComponent): string => { - const materializedSize = ImageTool.maxWidth || 1000 - const maxSize = materializedSize * getDevicePixelRatio() - const constrainedSrc = `${assetDocument.url}?w=${maxSize}&h=${maxSize}&fit=max` - return constrainedSrc - } - renderMaterializedAsset = (assetDocument: Record) => { - const {value = {}} = this.props - const constrainedSrc = this.getConstrainedImageSrc(assetDocument) - const srcAspectRatio = get(assetDocument, 'metadata.dimensions.aspectRatio') - return typeof srcAspectRatio === 'undefined' ? null : ( - - ) - } - - renderUploadState(uploadState: any) { - const {isUploading} = this.state - const isComplete = - uploadState.progress === 100 && !!(this.props.value && this.props.value.asset) - return ( -
-
-
- -
- {isUploading && ( - - )} -
-
- ) + handleRemoveButtonClick = (event: React.SyntheticEvent) => { + this.props.onChange(PatchEvent.from(unset(['asset']))) } handleFieldChange = (event: PatchEvent, field: FieldT) => { @@ -207,41 +235,95 @@ export default class ImageInput extends React.PureComponent { this.setState({isAdvancedEditOpen: false}) } - handleOpenSelectAsset = () => { - this.setState({ - isSelectAssetOpen: true - }) + handleSelectAssetFromSource = (assetFromSource: AssetFromSource) => { + const {onChange, type, resolveUploader} = this.props + if (!assetFromSource) { + throw new Error('No asset given') + } + if (!Array.isArray(assetFromSource) || assetFromSource.length === 0) { + throw new Error('Returned value must be an array with at least one item (asset)') + } + const firstAsset = assetFromSource[0] + const originalFilename = get(firstAsset, 'assetDocumentProps.originalFilename') + const label = get(firstAsset, 'assetDocumentProps.label') + const title = get(firstAsset, 'assetDocumentProps.title') + const description = get(firstAsset, 'assetDocumentProps.description') + const creditLine = get(firstAsset, 'assetDocumentProps.creditLine') + const source = get(firstAsset, 'assetDocumentProps.source') + switch (firstAsset.kind) { + case 'assetDocumentId': + onChange( + PatchEvent.from([ + setIfMissing({ + _type: type.name + }), + unset(['hotspot']), + unset(['crop']), + set( + { + _type: 'reference', + _ref: firstAsset.value + }, + ['asset'] + ) + ]) + ) + break + case 'file': + const uploader = resolveUploader(type, firstAsset.value) + this.uploadWith(uploader, firstAsset.value, {label, title, description, creditLine, source}) + break + case 'base64': + base64ToFile(firstAsset.value, originalFilename).then(file => { + const uploader = resolveUploader(type, file) + this.uploadWith(uploader, file, {label, title, description, creditLine, source}) + }) + break + case 'url': + urlToFile(firstAsset.value, originalFilename).then(file => { + const uploader = resolveUploader(type, file) + this.uploadWith(uploader, file, {label, title, description, creditLine, source}) + }) + break + default: { + throw new Error('Invalid value returned from asset source plugin') + } + } + this.setState({selectedAssetSource: null}) } - handleCloseSelectAsset = () => { + + handleFocus = (path: Path) => { this.setState({ - isSelectAssetOpen: false + hasFocus: true }) + this.props.onFocus(path) } - handleSelectAsset = (asset: Record) => { - const {onChange, type} = this.props - onChange( - PatchEvent.from([ - setIfMissing({ - _type: type.name - }), - unset(['hotspot']), - unset(['crop']), - set( - { - _type: 'reference', - _ref: asset._id - }, - ['asset'] - ) - ]) - ) + + handleBlur = event => { + this.props.onBlur() this.setState({ - isSelectAssetOpen: false + hasFocus: false }) } - isImageToolEnabled() { - return get(this.props.type, 'options.hotspot') === true + handleCancelUpload = () => { + this.cancelUpload() + } + + handleSelectFile = (files: FileList) => { + this.uploadFirstAccepted(files) + } + + handleUpload = ({file, uploader}) => { + this.uploadWith(uploader, file) + } + + handleSelectImageFromAssetSource = source => { + this.setState({selectedAssetSource: source}) + } + + handleAssetSourceClosed = () => { + this.setState({selectedAssetSource: null}) } renderAdvancedEdit(fields: Array) { @@ -268,6 +350,21 @@ export default class ImageInput extends React.PureComponent) => { + const {value = {}} = this.props + const constrainedSrc = this.getConstrainedImageSrc(assetDocument) + const srcAspectRatio = get(assetDocument, 'metadata.dimensions.aspectRatio') + return typeof srcAspectRatio === 'undefined' ? null : ( + + ) + } + renderFields(fields: Array) { return fields.map(field => this.renderField(field)) } @@ -292,39 +389,111 @@ export default class ImageInput extends React.PureComponent +
+
+ +
+ {isUploading && ( + + )} +
+
+ ) } - handleFocus = (path: Path) => { - this.setState({ - hasFocus: true - }) - this.props.onFocus(path) - } - handleBlur = event => { - this.props.onBlur() - this.setState({ - hasFocus: false - }) - } - setFocusArea = (el: any | null) => { - this._focusArea = el + renderDropDownMenuItem = item => { + if (!item) { + return null + } + const Icon = item.icon || ImageIcon + return ( +
+
+ +
+
{item.title}
+
+ ) } - getUploadOptions = (file: File): Array => { - const {type, resolveUploader} = this.props - const uploader = resolveUploader && resolveUploader(type, file) - return uploader ? [{type: type, uploader}] : [] + + renderSelectImageButton() { + // If there are multiple asset sources render a dropdown + if (this.assetSources.length > 1) { + return ( + + Select from + + ) + } + // Single asset source (just a normal button) + return ( + + ) } - handleUpload = ({file, uploader}) => { - this.uploadWith(uploader, file) + + renderAssetSource() { + const {selectedAssetSource} = this.state + const {value, materialize} = this.props + if (!selectedAssetSource) { + return null + } + const Component = selectedAssetSource.component + if (value && value.asset) { + return ( + + {imageAsset => { + return ( + + ) + }} + + ) + } + return ( + + ) } render() { const {type, value, level, materialize, markers, readOnly} = this.props - const {isAdvancedEditOpen, isSelectAssetOpen, uploadError, hasFocus} = this.state + const {isAdvancedEditOpen, selectedAssetSource, uploadError, hasFocus} = this.state const [highlightedFields, otherFields] = partition( type.fields.filter(field => !HIDDEN_FIELDS.includes(field.name)), 'type.options.isHighlighted' @@ -333,17 +502,20 @@ export default class ImageInput extends React.PureComponent 0 || (hasAsset && this.isImageToolEnabled())) + const FieldSetComponent = SUPPORT_DIRECT_UPLOADS ? UploadTargetFieldset : Fieldset + const uploadProps = SUPPORT_DIRECT_UPLOADS + ? {getUploadOptions: this.getUploadOptions, onUpload: this.handleUpload} + : {} return ( - {uploadError && ( Field is read only ) : ( - + SUPPORT_DIRECT_UPLOADS && )}
- {!readOnly && ( + {!readOnly && SUPPORT_DIRECT_UPLOADS && ( )} - {!readOnly && ( - - )} + {!readOnly && this.renderSelectImageButton()} {showAdvancedEditButton && (
)} {isAdvancedEditOpen && this.renderAdvancedEdit(otherFields)} - {isSelectAssetOpen && ( - - - - )} -
+ {selectedAssetSource && this.renderAssetSource()} + ) } } diff --git a/packages/@sanity/form-builder/src/inputs/ImageInput/styles/Asset.css b/packages/@sanity/form-builder/src/inputs/ImageInput/styles/Asset.css index 859d1cab046..3e65b13861b 100644 --- a/packages/@sanity/form-builder/src/inputs/ImageInput/styles/Asset.css +++ b/packages/@sanity/form-builder/src/inputs/ImageInput/styles/Asset.css @@ -25,6 +25,10 @@ overflow: hidden; cursor: default; flex-wrap: nowrap; + + @nest &.selected { + border: 4px solid var(--selected-item-color); + } } .padder { diff --git a/packages/@sanity/form-builder/src/inputs/ImageInput/styles/ImageInput.css b/packages/@sanity/form-builder/src/inputs/ImageInput/styles/ImageInput.css index 1489ba53b83..f0b0e3b7201 100644 --- a/packages/@sanity/form-builder/src/inputs/ImageInput/styles/ImageInput.css +++ b/packages/@sanity/form-builder/src/inputs/ImageInput/styles/ImageInput.css @@ -72,3 +72,19 @@ .advancedEditFields { margin-bottom: var(--medium-padding); } + +.selectDropDownAssetSourceItem { + color: inherit; + text-decoration: none; + display: flex; +} + +.selectDropDownAssetSourceIcon { + display: flex; + align-items: left; + justify-content: center; + align-self: center; + font-size: var(--font-size-large); + margin-right: var(--small-padding); +} + diff --git a/packages/@sanity/form-builder/src/inputs/ImageInput/utils/image.ts b/packages/@sanity/form-builder/src/inputs/ImageInput/utils/image.ts new file mode 100644 index 00000000000..1c1be8f909f --- /dev/null +++ b/packages/@sanity/form-builder/src/inputs/ImageInput/utils/image.ts @@ -0,0 +1,48 @@ +import UUID from '@sanity/uuid' + +export function urlToFile(url: string, filename?: string): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.onload = () => { + const reader = new FileReader() + reader.onloadend = () => { + const string = reader.result.toString() + const ext = string.substring('data:image/'.length, string.indexOf(';base64')) + if (!ext && !filename) { + return reject(new Error('Could not find mime type for image')) + } + resolve(dataURLtoFile(reader.result, filename || `${UUID()}.${ext}`)) + } + reader.readAsDataURL(xhr.response) + } + xhr.onerror = error => { + reject(error) + } + xhr.open('GET', url) + xhr.responseType = 'blob' + xhr.send() + }) +} + +export function base64ToFile(base64Data: string | ArrayBuffer, filename?: string): Promise { + return new Promise((resolve, reject) => { + const string = base64Data.toString() + const ext = string.substring('data:image/'.length, string.indexOf(';base64')) + if (!ext && !filename) { + return reject(new Error('Could not find mime type for image')) + } + resolve(dataURLtoFile(base64Data, filename || `${UUID()}.${ext}`)) + }) +} + +function dataURLtoFile(dataurl, filename) { + const arr = dataurl.split(',') + const mime = arr[0].match(/:(.*?);/)[1] + const bstr = atob(arr[1]) + let n = bstr.length + const u8arr = new Uint8Array(n) + while (n--) { + u8arr[n] = bstr.charCodeAt(n) + } + return new File([u8arr], filename, {type: mime}) +} diff --git a/packages/@sanity/form-builder/src/sanity/inputs/client-adapters/assets.ts b/packages/@sanity/form-builder/src/sanity/inputs/client-adapters/assets.ts index cf7b3873bd9..2965b0eff71 100644 --- a/packages/@sanity/form-builder/src/sanity/inputs/client-adapters/assets.ts +++ b/packages/@sanity/form-builder/src/sanity/inputs/client-adapters/assets.ts @@ -10,6 +10,7 @@ const MAX_CONCURRENT_UPLOADS = 4 function uploadSanityAsset(assetType, file, options: UploadOptions = {}) { const extract = options.metadata const preserveFilename = options.storeOriginalFilename + const {label, title, description, creditLine, source} = options return observableFrom(hashFile(file)).pipe( catchError(( error // ignore if hashing fails for some reason @@ -24,18 +25,28 @@ function uploadSanityAsset(assetType, file, options: UploadOptions = {}) { asset: existing }) } - return client.observable.assets.upload(assetType, file, {extract, preserveFilename}).pipe( - map((event: any) => - event.type === 'response' - ? { - // rewrite to a 'complete' event - type: 'complete', - id: event.body.document._id, - asset: event.body.document - } - : event + return client.observable.assets + .upload(assetType, file, { + extract, + preserveFilename, + label, + title, + description, + creditLine, + source + }) + .pipe( + map((event: any) => + event.type === 'response' + ? { + // rewrite to a 'complete' event + type: 'complete', + id: event.body.document._id, + asset: event.body.document + } + : event + ) ) - ) }) ) } @@ -46,7 +57,7 @@ export const uploadImageAsset = (file, options) => uploadAsset('image', file, op export const uploadFileAsset = (file, options) => uploadAsset('file', file, options) export function materializeReference(id) { - return observePaths(id, ['originalFilename', 'url', 'metadata']) + return observePaths(id, ['originalFilename', 'url', 'metadata', 'label', 'title', 'description', 'creditLine', 'source']) } function fetchExisting(type, hash) { diff --git a/packages/@sanity/form-builder/src/sanity/uploads/typedefs.ts b/packages/@sanity/form-builder/src/sanity/uploads/typedefs.ts index 3e4b72da446..f6fb671aa0d 100644 --- a/packages/@sanity/form-builder/src/sanity/uploads/typedefs.ts +++ b/packages/@sanity/form-builder/src/sanity/uploads/typedefs.ts @@ -10,8 +10,17 @@ export type UploadEvent = { export type ResolvedUploader = {uploader: Uploader; type: Type} export type UploadOptions = { - metadata?: Array | null - storeOriginalFilename?: boolean | null + metadata?: Array + storeOriginalFilename?: boolean + label?: string + title?: string + description?: string + creditLine?: string + source?: { + id: string + name: string + url?: string + } } export type UploaderDef = { diff --git a/packages/example-studio/config/.checksums b/packages/example-studio/config/.checksums index e1a78c2e819..e6a7585c1c9 100644 --- a/packages/example-studio/config/.checksums +++ b/packages/example-studio/config/.checksums @@ -3,5 +3,6 @@ "@sanity/default-layout": "bb034f391ba508a6ca8cd971967cbedeb131c4d19b17b28a0895f32db5d568ea", "@sanity/data-aspects": "ba5c2649cc1b1c39ae92b7daf2661f95fa79d7325073ffd410245d2717b240e9", "@sanity/storybook": "526dea3b461fda217e7150d12395d0ec639cba0155c05a084b85bcf2c44995a3", - "@sanity/default-login": "6fb6d3800aa71346e1b84d95bbcaa287879456f2922372bb0294e30b968cd37f" + "@sanity/default-login": "6fb6d3800aa71346e1b84d95bbcaa287879456f2922372bb0294e30b968cd37f", + "@sanity/form-builder": "b38478227ba5e22c91981da4b53436df22e48ff25238a55a973ed620be5068aa" } diff --git a/packages/example-studio/config/@sanity/form-builder.json b/packages/example-studio/config/@sanity/form-builder.json new file mode 100644 index 00000000000..b97a6a6d9d7 --- /dev/null +++ b/packages/example-studio/config/@sanity/form-builder.json @@ -0,0 +1,5 @@ +{ + "images": { + "directUploads": true + } +} diff --git a/packages/test-studio/config/.checksums b/packages/test-studio/config/.checksums index 759750f25b2..5f2aad8ef1e 100644 --- a/packages/test-studio/config/.checksums +++ b/packages/test-studio/config/.checksums @@ -4,5 +4,6 @@ "@sanity/data-aspects": "ba5c2649cc1b1c39ae92b7daf2661f95fa79d7325073ffd410245d2717b240e9", "@sanity/storybook": "526dea3b461fda217e7150d12395d0ec639cba0155c05a084b85bcf2c44995a3", "@sanity/google-maps-input": "57ae3a403ce6a070b31ec6fa1f3c8339cafa66661eaddba1d4d5ee3cc2197ec2", - "@sanity/default-login": "6fb6d3800aa71346e1b84d95bbcaa287879456f2922372bb0294e30b968cd37f" + "@sanity/default-login": "6fb6d3800aa71346e1b84d95bbcaa287879456f2922372bb0294e30b968cd37f", + "@sanity/form-builder": "b38478227ba5e22c91981da4b53436df22e48ff25238a55a973ed620be5068aa" } diff --git a/packages/test-studio/config/@sanity/form-builder.json b/packages/test-studio/config/@sanity/form-builder.json new file mode 100644 index 00000000000..b97a6a6d9d7 --- /dev/null +++ b/packages/test-studio/config/@sanity/form-builder.json @@ -0,0 +1,5 @@ +{ + "images": { + "directUploads": true + } +}