From ab685e85943d1ac48142f157683bc2126fd6af16 Mon Sep 17 00:00:00 2001 From: Erez Rokah Date: Thu, 13 Feb 2020 02:12:36 +0200 Subject: [PATCH] fix: change getAsset to not return a promise (#3232) * fix: change getAsset to not return a promise * fix: update markdown widget per getAsset changes * test: fix editor component image test * docs: update getAsset docs --- .eslintrc.js | 6 + .../src/actions/__tests__/media.js | 110 +++++++----------- .../netlify-cms-core/src/actions/media.ts | 89 +++++++++++--- .../Collection/Entries/EntryCard.js | 33 +++--- .../src/components/Editor/Editor.js | 26 +---- .../Editor/EditorControlPane/EditorControl.js | 44 ++++--- .../src/components/Editor/EditorInterface.js | 3 - .../EditorPreviewPane/EditorPreviewPane.js | 33 +++++- .../Editor/EditorPreviewPane/PreviewHOC.js | 3 +- .../src/reducers/__tests__/medias.spec.ts | 36 +++++- .../netlify-cms-core/src/reducers/medias.ts | 30 ++++- .../netlify-cms-core/src/routing/history.js | 2 +- packages/netlify-cms-core/src/types/redux.ts | 6 +- .../src/__tests__/index.spec.js | 6 +- .../src/index.js | 4 +- packages/netlify-cms-ui-default/src/Asset.js | 50 -------- packages/netlify-cms-ui-default/src/index.js | 3 - .../src/FilePreview.js | 21 ++-- .../src/withFileControl.js | 26 ++--- .../src/ImagePreview.js | 13 +-- .../src/ListControl.js | 2 +- .../src/MarkdownControl/RawEditor.js | 9 +- .../plugins/CopyPasteVisual.js | 9 +- .../src/MarkdownPreview.js | 37 +----- .../src/serializers/index.js | 6 +- .../src/serializers/remarkRehypeShortcodes.js | 10 +- .../src/RelationControl.js | 2 +- setupTestFramework.js | 1 + website/content/docs/architecture.md | 2 +- website/content/docs/customization.md | 50 ++------ 30 files changed, 317 insertions(+), 355 deletions(-) delete mode 100644 packages/netlify-cms-ui-default/src/Asset.js diff --git a/.eslintrc.js b/.eslintrc.js index 0d1dab7d..d79a6236 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,6 +30,12 @@ module.exports = { 'emotion/styled-import': 'error', 'require-atomic-updates': [0], 'object-shorthand': ['error', 'always'], + 'prefer-const': [ + 'error', + { + destructuring: 'all', + }, + ], }, plugins: ['babel', 'emotion', 'cypress'], settings: { diff --git a/packages/netlify-cms-core/src/actions/__tests__/media.js b/packages/netlify-cms-core/src/actions/__tests__/media.js index a596cc7e..3baa915a 100644 --- a/packages/netlify-cms-core/src/actions/__tests__/media.js +++ b/packages/netlify-cms-core/src/actions/__tests__/media.js @@ -1,5 +1,5 @@ import { Map } from 'immutable'; -import { getAsset, ADD_ASSET } from '../media'; +import { getAsset, ADD_ASSET, LOAD_ASSET_REQUEST } from '../media'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import AssetProxy from '../../valueObjects/AssetProxy'; @@ -9,15 +9,19 @@ const mockStore = configureMockStore(middlewares); jest.mock('../../reducers/entries'); jest.mock('../mediaLibrary'); -jest.mock('../../reducers/mediaLibrary'); describe('media', () => { + const emptyAsset = new AssetProxy({ + path: 'empty.svg', + file: new File([``], 'empty.svg', { + type: 'image/svg+xml', + }), + }); + describe('getAsset', () => { global.URL = { createObjectURL: jest.fn() }; const { selectMediaFilePath } = require('../../reducers/entries'); - const { selectMediaFileByPath } = require('../../reducers/mediaLibrary'); - const { getMediaDisplayURL, getMediaFile } = require('../mediaLibrary'); beforeEach(() => { jest.resetAllMocks(); @@ -28,12 +32,10 @@ describe('media', () => { const payload = { collection: null, entryPath: null, path: null }; - return store.dispatch(getAsset(payload)).then(result => { - const actions = store.getActions(); - expect(actions).toHaveLength(0); - - expect(result).toEqual(new AssetProxy({ file: new File([], 'empty'), path: '' })); - }); + const result = store.dispatch(getAsset(payload)); + const actions = store.getActions(); + expect(actions).toHaveLength(0); + expect(result).toEqual(emptyAsset); }); it('should return asset from medias state', () => { @@ -42,27 +44,26 @@ describe('media', () => { const store = mockStore({ config: Map(), medias: Map({ - [path]: asset, + [path]: { asset }, }), }); selectMediaFilePath.mockReturnValue(path); const payload = { collection: Map(), entry: Map({ path: 'entryPath' }), path }; - return store.dispatch(getAsset(payload)).then(result => { - const actions = store.getActions(); - expect(actions).toHaveLength(0); + const result = store.dispatch(getAsset(payload)); + const actions = store.getActions(); + expect(actions).toHaveLength(0); - expect(result).toBe(asset); - expect(selectMediaFilePath).toHaveBeenCalledTimes(1); - expect(selectMediaFilePath).toHaveBeenCalledWith( - store.getState().config, - payload.collection, - payload.entry, - path, - undefined, - ); - }); + expect(result).toBe(asset); + expect(selectMediaFilePath).toHaveBeenCalledTimes(1); + expect(selectMediaFilePath).toHaveBeenCalledWith( + store.getState().config, + payload.collection, + payload.entry, + path, + undefined, + ); }); it('should create asset for absolute path when not in medias state', () => { @@ -76,64 +77,33 @@ describe('media', () => { selectMediaFilePath.mockReturnValue(path); const payload = { collection: null, entryPath: null, path }; - return store.dispatch(getAsset(payload)).then(result => { - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toEqual({ - type: ADD_ASSET, - payload: asset, - }); - expect(result).toEqual(asset); + const result = store.dispatch(getAsset(payload)); + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual({ + type: ADD_ASSET, + payload: asset, }); + expect(result).toEqual(asset); }); - it('should create asset from media file when not in medias state', () => { + it('should return empty asset and initiate load when not in medias state', () => { const path = 'static/media/image.png'; - const mediaFile = { file: new File([], '') }; - const url = 'blob://displayURL'; - const asset = new AssetProxy({ url, path }); const store = mockStore({ medias: Map({}), }); selectMediaFilePath.mockReturnValue(path); - selectMediaFileByPath.mockReturnValue(mediaFile); - getMediaDisplayURL.mockResolvedValue(url); const payload = { path }; - return store.dispatch(getAsset(payload)).then(result => { - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toEqual({ - type: ADD_ASSET, - payload: asset, - }); - expect(result).toEqual(asset); - }); - }); - - it('should fetch asset media file when not in redux store', () => { - const path = 'static/media/image.png'; - const url = 'blob://displayURL'; - const asset = new AssetProxy({ url, path }); - const store = mockStore({ - medias: Map({}), - }); - - selectMediaFilePath.mockReturnValue(path); - selectMediaFileByPath.mockReturnValue(undefined); - getMediaFile.mockResolvedValue({ url }); - const payload = { path }; - - return store.dispatch(getAsset(payload)).then(result => { - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toEqual({ - type: ADD_ASSET, - payload: asset, - }); - expect(result).toEqual(asset); + const result = store.dispatch(getAsset(payload)); + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual({ + type: LOAD_ASSET_REQUEST, + payload: { path }, }); + expect(result).toEqual(emptyAsset); }); }); }); diff --git a/packages/netlify-cms-core/src/actions/media.ts b/packages/netlify-cms-core/src/actions/media.ts index bf185aad..b246de6c 100644 --- a/packages/netlify-cms-core/src/actions/media.ts +++ b/packages/netlify-cms-core/src/actions/media.ts @@ -11,6 +11,10 @@ export const ADD_ASSETS = 'ADD_ASSETS'; export const ADD_ASSET = 'ADD_ASSET'; export const REMOVE_ASSET = 'REMOVE_ASSET'; +export const LOAD_ASSET_REQUEST = 'LOAD_ASSET_REQUEST'; +export const LOAD_ASSET_SUCCESS = 'LOAD_ASSET_SUCCESS'; +export const LOAD_ASSET_FAILURE = 'LOAD_ASSET_FAILURE'; + export function addAssets(assets: AssetProxy[]) { return { type: ADD_ASSETS, payload: assets }; } @@ -23,6 +27,42 @@ export function removeAsset(path: string) { return { type: REMOVE_ASSET, payload: path }; } +export function loadAssetRequest(path: string) { + return { type: LOAD_ASSET_REQUEST, payload: { path } }; +} + +export function loadAssetSuccess(path: string) { + return { type: LOAD_ASSET_SUCCESS, payload: { path } }; +} + +export function loadAssetFailure(path: string, error: Error) { + return { type: LOAD_ASSET_FAILURE, payload: { path, error } }; +} + +export function loadAsset(resolvedPath: string) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + try { + dispatch(loadAssetRequest(resolvedPath)); + // load asset url from backend + await waitForMediaLibraryToLoad(dispatch, getState()); + const file = selectMediaFileByPath(getState(), resolvedPath); + + if (file) { + const url = await getMediaDisplayURL(dispatch, getState(), file); + const asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath }); + dispatch(addAsset(asset)); + } else { + const { url } = await getMediaFile(getState(), resolvedPath); + const asset = createAssetProxy({ path: resolvedPath, url }); + dispatch(addAsset(asset)); + } + dispatch(loadAssetSuccess(resolvedPath)); + } catch (e) { + dispatch(loadAssetFailure(resolvedPath, e)); + } + }; +} + interface GetAssetArgs { collection: Collection; entry: EntryMap; @@ -30,38 +70,51 @@ interface GetAssetArgs { folder?: string; } +const emptyAsset = createAssetProxy({ + path: 'empty.svg', + file: new File([``], 'empty.svg', { + type: 'image/svg+xml', + }), +}); + +export function boundGetAsset( + dispatch: ThunkDispatch, + collection: Collection, + entry: EntryMap, +) { + const bound = (path: string, folder: string) => { + const asset = dispatch(getAsset({ collection, entry, path, folder })); + return asset; + }; + + return bound; +} + export function getAsset({ collection, entry, path, folder }: GetAssetArgs) { - return async (dispatch: ThunkDispatch, getState: () => State) => { - if (!path) return createAssetProxy({ path: '', file: new File([], 'empty') }); + return (dispatch: ThunkDispatch, getState: () => State) => { + if (!path) return emptyAsset; + const state = getState(); const resolvedPath = selectMediaFilePath(state.config, collection, entry, path, folder); - let asset = state.medias.get(resolvedPath); - if (asset) { + let { asset, isLoading, error } = state.medias.get(resolvedPath) || {}; + if (isLoading) { + return emptyAsset; + } + if (asset && !error) { // There is already an AssetProxy in memory for this path. Use it. return asset; } - // Create a new AssetProxy (for consistency) and return it. if (isAbsolutePath(resolvedPath)) { // asset path is a public url so we can just use it as is asset = createAssetProxy({ path: resolvedPath, url: path }); + dispatch(addAsset(asset)); } else { - // load asset url from backend - await waitForMediaLibraryToLoad(dispatch, getState()); - const file = selectMediaFileByPath(state, resolvedPath); - - if (file) { - const url = await getMediaDisplayURL(dispatch, getState(), file); - asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath }); - } else { - const { url } = await getMediaFile(state, resolvedPath); - asset = createAssetProxy({ path: resolvedPath, url }); - } + dispatch(loadAsset(resolvedPath)); + asset = emptyAsset; } - dispatch(addAsset(asset)); - return asset; }; } diff --git a/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js b/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js index 7e06e107..babe0f71 100644 --- a/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js +++ b/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js @@ -1,12 +1,13 @@ import React from 'react'; import styled from '@emotion/styled'; import { connect } from 'react-redux'; -import { getAsset } from 'Actions/media'; +import { boundGetAsset } from 'Actions/media'; import { Link } from 'react-router-dom'; -import { colors, colorsRaw, components, lengths, Asset } from 'netlify-cms-ui-default'; +import { colors, colorsRaw, components, lengths } from 'netlify-cms-ui-default'; import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews'; import { summaryFormatter } from 'Lib/formatters'; import { keyToPathArray } from 'Lib/stringTemplate'; +import { selectIsLoadingAsset } from 'Reducers/medias'; const ListCard = styled.li` ${components.card}; @@ -77,17 +78,13 @@ const CardBody = styled.div` `; const CardImage = styled.div` - background-image: url(${props => props.value?.toString()}); + background-image: url(${props => props.src}); background-position: center center; background-size: cover; background-repeat: no-repeat; height: 150px; `; -const CardImageAsset = ({ getAsset, image, folder }) => { - return ; -}; - const EntryCard = ({ path, summary, @@ -95,7 +92,7 @@ const EntryCard = ({ imageFolder, collectionLabel, viewStyle = VIEW_STYLE_LIST, - boundGetAsset, + getAsset, }) => { if (viewStyle === VIEW_STYLE_LIST) { return ( @@ -108,6 +105,9 @@ const EntryCard = ({ ); } + const asset = getAsset(image, imageFolder); + const src = asset.toString(); + if (viewStyle === VIEW_STYLE_GRID) { return ( @@ -116,9 +116,7 @@ const EntryCard = ({ {collectionLabel ? {collectionLabel} : null} {summary} - {image ? ( - - ) : null} + {image ? : null} ); @@ -142,6 +140,8 @@ const mapStateToProps = (state, ownProps) => { image = encodeURI(image); } + const isLoadingAsset = selectIsLoadingAsset(state.medias); + return { summary, path: `/collections/${collection.get('name')}/entries/${entry.get('slug')}`, @@ -150,13 +150,14 @@ const mapStateToProps = (state, ownProps) => { .get('fields') ?.find(f => f.get('name') === inferedFields.imageField && f.get('widget') === 'image') ?.get('media_folder'), + isLoadingAsset, }; }; -const mapDispatchToProps = { - boundGetAsset: (collection, entry) => (dispatch, getState) => (path, folder) => { - return getAsset({ collection, entry, path, folder })(dispatch, getState); - }, +const mapDispatchToProps = dispatch => { + return { + boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry), + }; }; const mergeProps = (stateProps, dispatchProps, ownProps) => { @@ -164,7 +165,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { ...stateProps, ...dispatchProps, ...ownProps, - boundGetAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry), + getAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry), }; }; diff --git a/packages/netlify-cms-core/src/components/Editor/Editor.js b/packages/netlify-cms-core/src/components/Editor/Editor.js index 7492e09d..7b1279b8 100644 --- a/packages/netlify-cms-core/src/components/Editor/Editor.js +++ b/packages/netlify-cms-core/src/components/Editor/Editor.js @@ -32,7 +32,6 @@ import { import { loadDeployPreview } from 'Actions/deploys'; import { deserializeValues } from 'Lib/serializeEntryValues'; import { selectEntry, selectUnpublishedEntry, selectDeployPreview } from 'Reducers'; -import { getAsset } from 'Actions/media'; import { selectFields } from 'Reducers/collections'; import { status, EDITORIAL_WORKFLOW } from 'Constants/publishModes'; import EditorInterface from './EditorInterface'; @@ -46,7 +45,6 @@ const navigateToEntry = (collectionName, slug) => export class Editor extends React.Component { static propTypes = { - boundGetAsset: PropTypes.func.isRequired, changeDraftField: PropTypes.func.isRequired, changeDraftFieldValidation: PropTypes.func.isRequired, collection: ImmutablePropTypes.map.isRequired, @@ -379,7 +377,6 @@ export class Editor extends React.Component { entry, entryDraft, fields, - boundGetAsset, collection, changeDraftField, changeDraftFieldValidation, @@ -420,7 +417,6 @@ export class Editor extends React.Component { (dispatch, getState) => (path, folder) => { - return getAsset({ collection, entry, path, folder })(dispatch, getState); - }, }; -const mergeProps = (stateProps, dispatchProps, ownProps) => { - return { - ...stateProps, - ...dispatchProps, - ...ownProps, - boundGetAsset: dispatchProps.boundGetAsset( - stateProps.collection, - stateProps.entryDraft.get('entry'), - ), - }; -}; - -export default connect( - mapStateToProps, - mapDispatchToProps, - mergeProps, -)(withWorkflow(translate()(Editor))); +export default connect(mapStateToProps, mapDispatchToProps)(withWorkflow(translate()(Editor))); diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index 5a8927b2..d6d93864 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -1,4 +1,5 @@ import React from 'react'; +import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { translate } from 'react-polyglot'; @@ -9,7 +10,8 @@ import { connect } from 'react-redux'; import { FieldLabel, colors, transitions, lengths, borders } from 'netlify-cms-ui-default'; import { resolveWidget, getEditorComponents } from 'Lib/registry'; import { clearFieldErrors, loadEntry } from 'Actions/entries'; -import { addAsset, getAsset } from 'Actions/media'; +import { addAsset, boundGetAsset } from 'Actions/media'; +import { selectIsLoadingAsset } from 'Reducers/medias'; import { query, clearSearch } from 'Actions/search'; import { openMediaLibrary, @@ -265,6 +267,7 @@ const mapStateToProps = state => { const { collections, entryDraft } = state; const entry = entryDraft.get('entry'); const collection = collections.get(entryDraft.getIn(['entry', 'collection'])); + const isLoadingAsset = selectIsLoadingAsset(state.medias); return { mediaPaths: state.mediaLibrary.get('controlMedia'), @@ -272,25 +275,32 @@ const mapStateToProps = state => { queryHits: state.search.get('queryHits'), collection, entry, + isLoadingAsset, }; }; -const mapDispatchToProps = { - openMediaLibrary, - clearMediaControl, - removeMediaControl, - removeInsertedMedia, - addAsset, - query, - loadEntry: (collectionName, slug) => (dispatch, getState) => { - const collection = getState().collections.get(collectionName); - return loadEntry(collection, slug)(dispatch, getState); - }, - clearSearch, - clearFieldErrors, - boundGetAsset: (collection, entry) => (dispatch, getState) => (path, folder) => { - return getAsset({ collection, entry, path, folder })(dispatch, getState); - }, +const mapDispatchToProps = dispatch => { + const creators = bindActionCreators( + { + openMediaLibrary, + clearMediaControl, + removeMediaControl, + removeInsertedMedia, + addAsset, + query, + clearSearch, + clearFieldErrors, + }, + dispatch, + ); + return { + ...creators, + loadEntry: (collectionName, slug) => (dispatch, getState) => { + const collection = getState().collections.get(collectionName); + return loadEntry(collection, slug)(dispatch, getState); + }, + boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry), + }; }; const mergeProps = (stateProps, dispatchProps, ownProps) => { diff --git a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js index 4478ae31..bdbb43b4 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js @@ -156,7 +156,6 @@ class EditorInterface extends Component { fields, fieldsMetaData, fieldsErrors, - getAsset, onChange, showDelete, onDelete, @@ -218,7 +217,6 @@ class EditorInterface extends Component { entry={entry} fields={fields} fieldsMetaData={fieldsMetaData} - getAsset={getAsset} /> @@ -297,7 +295,6 @@ EditorInterface.propTypes = { fields: ImmutablePropTypes.list.isRequired, fieldsMetaData: ImmutablePropTypes.map.isRequired, fieldsErrors: ImmutablePropTypes.map.isRequired, - getAsset: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, onValidate: PropTypes.func.isRequired, onPersist: PropTypes.func.isRequired, diff --git a/packages/netlify-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js b/packages/netlify-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js index 654a68d5..d83f12a2 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js @@ -8,6 +8,9 @@ import { lengths } from 'netlify-cms-ui-default'; import { resolveWidget, getPreviewTemplate, getPreviewStyles } from 'Lib/registry'; import { ErrorBoundary } from 'UI'; import { selectTemplateName, selectInferedField, selectField } from 'Reducers/collections'; +import { connect } from 'react-redux'; +import { boundGetAsset } from 'Actions/media'; +import { selectIsLoadingAsset } from 'Reducers/medias'; import { INFERABLE_FIELDS } from 'Constants/fieldInference'; import EditorPreviewContent from './EditorPreviewContent.js'; import PreviewHOC from './PreviewHOC'; @@ -21,7 +24,7 @@ const PreviewPaneFrame = styled(Frame)` border-radius: ${lengths.borderRadius}; `; -export default class PreviewPane extends React.Component { +export class PreviewPane extends React.Component { getWidget = (field, value, metadata, props, idx = null) => { const { getAsset, entry } = props; const widget = resolveWidget(field.get('widget')); @@ -74,9 +77,9 @@ export default class PreviewPane extends React.Component { // custom preview templates, where the field object can't be passed in. let field = fields && fields.find(f => f.get('name') === name); let value = values && values.get(field.get('name')); - let nestedFields = field.get('fields'); - let singleField = field.get('field'); - let metadata = fieldsMetaData && fieldsMetaData.get(field.get('name'), Map()); + const nestedFields = field.get('fields'); + const singleField = field.get('field'); + const metadata = fieldsMetaData && fieldsMetaData.get(field.get('name'), Map()); if (nestedFields) { field = field.set('fields', this.getNestedWidgets(nestedFields, value, metadata)); @@ -233,3 +236,25 @@ PreviewPane.propTypes = { fieldsMetaData: ImmutablePropTypes.map.isRequired, getAsset: PropTypes.func.isRequired, }; + +const mapStateToProps = state => { + const isLoadingAsset = selectIsLoadingAsset(state.medias); + return { isLoadingAsset }; +}; + +const mapDispatchToProps = dispatch => { + return { + boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry), + }; +}; + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + return { + ...stateProps, + ...dispatchProps, + ...ownProps, + getAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(PreviewPane); diff --git a/packages/netlify-cms-core/src/components/Editor/EditorPreviewPane/PreviewHOC.js b/packages/netlify-cms-core/src/components/Editor/EditorPreviewPane/PreviewHOC.js index ba9bd2a8..e00fb762 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorPreviewPane/PreviewHOC.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorPreviewPane/PreviewHOC.js @@ -13,7 +13,8 @@ class PreviewHOC extends React.Component { return ( isWidgetContainer || this.props.value !== nextProps.value || - this.props.fieldsMetaData !== nextProps.fieldsMetaData + this.props.fieldsMetaData !== nextProps.fieldsMetaData || + this.props.getAsset !== nextProps.getAsset ); } diff --git a/packages/netlify-cms-core/src/reducers/__tests__/medias.spec.ts b/packages/netlify-cms-core/src/reducers/__tests__/medias.spec.ts index c6a592bf..191cef0b 100644 --- a/packages/netlify-cms-core/src/reducers/__tests__/medias.spec.ts +++ b/packages/netlify-cms-core/src/reducers/__tests__/medias.spec.ts @@ -1,5 +1,12 @@ import { Map, fromJS } from 'immutable'; -import { addAssets, addAsset, removeAsset } from '../../actions/media'; +import { + addAssets, + addAsset, + removeAsset, + loadAssetRequest, + loadAssetSuccess, + loadAssetFailure, +} from '../../actions/media'; import reducer from '../medias'; import { createAssetProxy } from '../../valueObjects/AssetProxy'; @@ -7,14 +14,37 @@ describe('medias', () => { const asset = createAssetProxy({ url: 'url', path: 'path' }); it('should add assets', () => { - expect(reducer(fromJS({}), addAssets([asset]))).toEqual(Map({ path: asset })); + expect(reducer(fromJS({}), addAssets([asset]))).toEqual( + Map({ path: { asset, isLoading: false, error: null } }), + ); }); it('should add asset', () => { - expect(reducer(fromJS({}), addAsset(asset))).toEqual(Map({ path: asset })); + expect(reducer(fromJS({}), addAsset(asset))).toEqual( + Map({ path: { asset, isLoading: false, error: null } }), + ); }); it('should remove asset', () => { expect(reducer(fromJS({ path: asset }), removeAsset(asset.path))).toEqual(Map()); }); + + it('should mark asset as loading', () => { + expect(reducer(fromJS({}), loadAssetRequest(asset.path))).toEqual( + Map({ path: { isLoading: true } }), + ); + }); + + it('should mark asset as not loading', () => { + expect(reducer(fromJS({}), loadAssetSuccess(asset.path))).toEqual( + Map({ path: { isLoading: false, error: null } }), + ); + }); + + it('should set loading error', () => { + const error = new Error('some error'); + expect(reducer(fromJS({}), loadAssetFailure(asset.path, error))).toEqual( + Map({ path: { isLoading: false, error } }), + ); + }); }); diff --git a/packages/netlify-cms-core/src/reducers/medias.ts b/packages/netlify-cms-core/src/reducers/medias.ts index 3e19f3d7..5c4674ba 100644 --- a/packages/netlify-cms-core/src/reducers/medias.ts +++ b/packages/netlify-cms-core/src/reducers/medias.ts @@ -1,5 +1,12 @@ import { fromJS } from 'immutable'; -import { ADD_ASSETS, ADD_ASSET, REMOVE_ASSET } from '../actions/media'; +import { + ADD_ASSETS, + ADD_ASSET, + REMOVE_ASSET, + LOAD_ASSET_REQUEST, + LOAD_ASSET_SUCCESS, + LOAD_ASSET_FAILURE, +} from '../actions/media'; import AssetProxy from '../valueObjects/AssetProxy'; import { Medias, MediasAction } from '../types/redux'; @@ -9,21 +16,36 @@ const medias = (state: Medias = fromJS({}), action: MediasAction) => { const payload = action.payload as AssetProxy[]; let newState = state; payload.forEach(asset => { - newState = newState.set(asset.path, asset); + newState = newState.set(asset.path, { asset, isLoading: false, error: null }); }); return newState; } case ADD_ASSET: { - const payload = action.payload as AssetProxy; - return state.set(payload.path, payload); + const asset = action.payload as AssetProxy; + return state.set(asset.path, { asset, isLoading: false, error: null }); } case REMOVE_ASSET: { const payload = action.payload as string; return state.delete(payload); } + case LOAD_ASSET_REQUEST: { + const { path } = action.payload as { path: string }; + return state.set(path, { ...state.get(path), isLoading: true }); + } + case LOAD_ASSET_SUCCESS: { + const { path } = action.payload as { path: string }; + return state.set(path, { ...state.get(path), isLoading: false, error: null }); + } + case LOAD_ASSET_FAILURE: { + const { path, error } = action.payload as { path: string; error: Error }; + return state.set(path, { ...state.get(path), isLoading: false, error }); + } default: return state; } }; +export const selectIsLoadingAsset = (state: Medias) => + Object.values(state.toJS()).some(state => state.isLoading); + export default medias; diff --git a/packages/netlify-cms-core/src/routing/history.js b/packages/netlify-cms-core/src/routing/history.js index 531bd2eb..b60d6143 100644 --- a/packages/netlify-cms-core/src/routing/history.js +++ b/packages/netlify-cms-core/src/routing/history.js @@ -1,5 +1,5 @@ import { createHashHistory } from 'history'; -let history = createHashHistory(); +const history = createHashHistory(); export default history; diff --git a/packages/netlify-cms-core/src/types/redux.ts b/packages/netlify-cms-core/src/types/redux.ts index f47fee4b..32694359 100644 --- a/packages/netlify-cms-core/src/types/redux.ts +++ b/packages/netlify-cms-core/src/types/redux.ts @@ -144,7 +144,9 @@ export type Collection = StaticallyTypedRecord; export type Collections = StaticallyTypedRecord<{ [path: string]: Collection & CollectionObject }>; -export type Medias = StaticallyTypedRecord<{ [path: string]: AssetProxy | undefined }>; +export type Medias = StaticallyTypedRecord<{ + [path: string]: { asset: AssetProxy | undefined; isLoading: boolean; error: Error | null }; +}>; export interface MediaLibraryInstance { show: (args: { @@ -216,7 +218,7 @@ export interface State { } export interface MediasAction extends Action { - payload: string | AssetProxy | AssetProxy[]; + payload: string | AssetProxy | AssetProxy[] | { path: string } | { path: string; error: Error }; } export interface ConfigAction extends Action { diff --git a/packages/netlify-cms-editor-component-image/src/__tests__/index.spec.js b/packages/netlify-cms-editor-component-image/src/__tests__/index.spec.js index 054d8385..6468dfc6 100644 --- a/packages/netlify-cms-editor-component-image/src/__tests__/index.spec.js +++ b/packages/netlify-cms-editor-component-image/src/__tests__/index.spec.js @@ -1,6 +1,6 @@ import component from '../index'; -const getAsset = path => Promise.resolve(path); +const getAsset = path => path; const image = '/image'; const alt = 'alt'; const title = 'title'; @@ -32,8 +32,8 @@ describe('editor component image', () => { ); }); - it('should generate valid react props', async () => { - await expect(component.toPreview({ image, alt, title }, getAsset)).resolves.toMatchObject({ + it('should generate valid react props', () => { + expect(component.toPreview({ image, alt, title }, getAsset)).toMatchObject({ props: { src: image, alt, title }, }); }); diff --git a/packages/netlify-cms-editor-component-image/src/index.js b/packages/netlify-cms-editor-component-image/src/index.js index dcf066f3..c0974734 100644 --- a/packages/netlify-cms-editor-component-image/src/index.js +++ b/packages/netlify-cms-editor-component-image/src/index.js @@ -12,10 +12,10 @@ const image = { toBlock: ({ alt, image, title }) => `![${alt || ''}](${image || ''}${title ? ` "${title.replace(/"/g, '\\"')}"` : ''})`, // eslint-disable-next-line react/display-name - toPreview: async ({ alt, image, title }, getAsset, fields) => { + toPreview: ({ alt, image, title }, getAsset, fields) => { const imageField = fields?.find(f => f.get('widget') === 'image'); const folder = imageField?.get('media_folder'); - const src = await getAsset(image, folder); + const src = getAsset(image, folder); return {alt; }, pattern: /^!\[(.*)\]\((.*?)(\s"(.*)")?\)$/, diff --git a/packages/netlify-cms-ui-default/src/Asset.js b/packages/netlify-cms-ui-default/src/Asset.js deleted file mode 100644 index 9bf8ab46..00000000 --- a/packages/netlify-cms-ui-default/src/Asset.js +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -class Asset extends React.Component { - static propTypes = { - path: PropTypes.string.isRequired, - getAsset: PropTypes.func.isRequired, - component: PropTypes.elementType.isRequired, - folder: PropTypes.string, - }; - - subscribed = true; - - state = { - value: null, - }; - - async _fetchAsset() { - const { getAsset, path, folder } = this.props; - const value = await getAsset(path, folder); - if (this.subscribed) { - this.setState({ value }); - } - } - - componentDidMount() { - this._fetchAsset(); - } - - componentWillUnmount() { - this.subscribed = false; - } - - componentDidUpdate(prevProps) { - if ( - prevProps.path !== this.props.path || - prevProps.getAsset !== this.props.getAsset || - prevProps.folder !== this.props.folder - ) { - this._fetchAsset(); - } - } - - render() { - const { component, ...props } = this.props; - return React.createElement(component, { ...props, value: this.state.value }); - } -} - -export default Asset; diff --git a/packages/netlify-cms-ui-default/src/index.js b/packages/netlify-cms-ui-default/src/index.js index 5e03884c..ccaceafa 100644 --- a/packages/netlify-cms-ui-default/src/index.js +++ b/packages/netlify-cms-ui-default/src/index.js @@ -9,7 +9,6 @@ import AuthenticationPage from './AuthenticationPage'; import WidgetPreviewContainer from './WidgetPreviewContainer'; import ObjectWidgetTopBar from './ObjectWidgetTopBar'; import GoBackButton from './GoBackButton'; -import Asset from './Asset'; import { fonts, colorsRaw, @@ -56,7 +55,6 @@ export const NetlifyCmsUiDefault = { effects, reactSelectStyles, GlobalStyles, - Asset, }; export { Dropdown, @@ -89,5 +87,4 @@ export { reactSelectStyles, GlobalStyles, GoBackButton, - Asset, }; diff --git a/packages/netlify-cms-widget-file/src/FilePreview.js b/packages/netlify-cms-widget-file/src/FilePreview.js index 027361bc..fba20f37 100644 --- a/packages/netlify-cms-widget-file/src/FilePreview.js +++ b/packages/netlify-cms-widget-file/src/FilePreview.js @@ -2,9 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import styled from '@emotion/styled'; import { List } from 'immutable'; -import { WidgetPreviewContainer, Asset } from 'netlify-cms-ui-default'; +import { WidgetPreviewContainer } from 'netlify-cms-ui-default'; -const FileLink = styled(({ value: href, path }) => ( +const FileLink = styled(({ href, path }) => ( {path} @@ -12,25 +12,24 @@ const FileLink = styled(({ value: href, path }) => ( display: block; `; -const FileLinkAsset = ({ value, getAsset }) => { - return ; -}; - -function FileLinkList({ values, getAsset }) { +function FileLinkList({ values, getAsset, folder }) { return (
{values.map(value => ( - + ))}
); } -function FileContent({ value, getAsset }) { +function FileContent(props) { + const { value, getAsset, field } = props; + const folder = field.get('media_folder'); + if (Array.isArray(value) || List.isList(value)) { - return ; + return ; } - return ; + return ; } const FilePreview = props => ( diff --git a/packages/netlify-cms-widget-file/src/withFileControl.js b/packages/netlify-cms-widget-file/src/withFileControl.js index 236b851e..074e2acc 100644 --- a/packages/netlify-cms-widget-file/src/withFileControl.js +++ b/packages/netlify-cms-widget-file/src/withFileControl.js @@ -6,15 +6,7 @@ import { Map, List } from 'immutable'; import { once } from 'lodash'; import uuid from 'uuid/v4'; import { oneLine } from 'common-tags'; -import { - lengths, - components, - buttons, - borders, - effects, - shadows, - Asset, -} from 'netlify-cms-ui-default'; +import { lengths, components, buttons, borders, effects, shadows } from 'netlify-cms-ui-default'; const MAX_DISPLAY_LENGTH = 50; @@ -31,15 +23,13 @@ const ImageWrapper = styled.div` ${shadows.inset}; `; -const Image = styled(({ value: src }) => )` +const StyledImage = styled.img` width: 100%; height: 100%; object-fit: contain; `; -const ImageAsset = ({ getAsset, value, folder }) => { - return ; -}; +const Image = props => ; const MultiImageWrapper = styled.div` display: flex; @@ -118,9 +108,9 @@ export default function withFileControl({ forImage } = {}) { shouldComponentUpdate(nextProps) { /** - * Always update if the value changes. + * Always update if the value or getAsset changes. */ - if (this.props.value !== nextProps.value) { + if (this.props.value !== nextProps.value || this.props.getAsset !== nextProps.getAsset) { return true; } @@ -228,15 +218,17 @@ export default function withFileControl({ forImage } = {}) { {value.map(val => ( - + ))} ); } + + const src = getAsset(value, folder); return ( - + ); }; diff --git a/packages/netlify-cms-widget-image/src/ImagePreview.js b/packages/netlify-cms-widget-image/src/ImagePreview.js index a1bf74df..da61b52a 100644 --- a/packages/netlify-cms-widget-image/src/ImagePreview.js +++ b/packages/netlify-cms-widget-image/src/ImagePreview.js @@ -2,23 +2,16 @@ import React from 'react'; import PropTypes from 'prop-types'; import styled from '@emotion/styled'; import { List } from 'immutable'; -import { WidgetPreviewContainer, Asset } from 'netlify-cms-ui-default'; +import { WidgetPreviewContainer } from 'netlify-cms-ui-default'; -const StyledImage = styled(({ value: src }) => )` +const StyledImage = styled(({ src }) => )` display: block; max-width: 100%; height: auto; `; const StyledImageAsset = ({ getAsset, value, field }) => { - return ( - - ); + return ; }; const ImagePreviewContent = props => { diff --git a/packages/netlify-cms-widget-list/src/ListControl.js b/packages/netlify-cms-widget-list/src/ListControl.js index 1749a361..28dea2a4 100644 --- a/packages/netlify-cms-widget-list/src/ListControl.js +++ b/packages/netlify-cms-widget-list/src/ListControl.js @@ -186,7 +186,7 @@ export default class ListControl extends React.Component { handleAddType = (type, typeKey) => { const { value, onChange } = this.props; - let parsedValue = fromJS(this.mixedDefault(typeKey, type)); + const parsedValue = fromJS(this.mixedDefault(typeKey, type)); this.setState({ itemsCollapsed: this.state.itemsCollapsed.push(false) }); onChange((value || List()).push(parsedValue)); }; diff --git a/packages/netlify-cms-widget-markdown/src/MarkdownControl/RawEditor.js b/packages/netlify-cms-widget-markdown/src/MarkdownControl/RawEditor.js index 45d3e78b..86d246bb 100644 --- a/packages/netlify-cms-widget-markdown/src/MarkdownControl/RawEditor.js +++ b/packages/netlify-cms-widget-markdown/src/MarkdownControl/RawEditor.js @@ -50,18 +50,17 @@ export default class RawEditor extends React.Component { } } - handleCopy = async (event, editor) => { - event.persist(); + handleCopy = (event, editor) => { const { getAsset, resolveWidget } = this.props; const markdown = Plain.serialize(Value.create({ document: editor.value.fragment })); - const html = await markdownToHtml(markdown, { getAsset, resolveWidget }); + const html = markdownToHtml(markdown, { getAsset, resolveWidget }); setEventTransfer(event, 'text', markdown); setEventTransfer(event, 'html', html); event.preventDefault(); }; - handleCut = async (event, editor, next) => { - await this.handleCopy(event, editor, next); + handleCut = (event, editor, next) => { + this.handleCopy(event, editor, next); editor.delete(); }; diff --git a/packages/netlify-cms-widget-markdown/src/MarkdownControl/plugins/CopyPasteVisual.js b/packages/netlify-cms-widget-markdown/src/MarkdownControl/plugins/CopyPasteVisual.js index adbef28a..054d014e 100644 --- a/packages/netlify-cms-widget-markdown/src/MarkdownControl/plugins/CopyPasteVisual.js +++ b/packages/netlify-cms-widget-markdown/src/MarkdownControl/plugins/CopyPasteVisual.js @@ -5,10 +5,9 @@ import isHotkey from 'is-hotkey'; import { slateToMarkdown, markdownToSlate, htmlToSlate, markdownToHtml } from '../../serializers'; const CopyPasteVisual = ({ getAsset, resolveWidget }) => { - const handleCopy = async (event, editor) => { - event.persist(); + const handleCopy = (event, editor) => { const markdown = slateToMarkdown(editor.value.fragment.toJS()); - const html = await markdownToHtml(markdown, { getAsset, resolveWidget }); + const html = markdownToHtml(markdown, { getAsset, resolveWidget }); setEventTransfer(event, 'text', markdown); setEventTransfer(event, 'html', html); setEventTransfer(event, 'fragment', base64.serializeNode(editor.value.fragment)); @@ -32,8 +31,8 @@ const CopyPasteVisual = ({ getAsset, resolveWidget }) => { const doc = Document.fromJSON(ast); return editor.insertFragment(doc); }, - async onCopy(event, editor, next) { - await handleCopy(event, editor, next); + onCopy(event, editor, next) { + handleCopy(event, editor, next); }, onCut(event, editor, next) { handleCopy(event, editor, next); diff --git a/packages/netlify-cms-widget-markdown/src/MarkdownPreview.js b/packages/netlify-cms-widget-markdown/src/MarkdownPreview.js index 1da26b20..a6f672e5 100644 --- a/packages/netlify-cms-widget-markdown/src/MarkdownPreview.js +++ b/packages/netlify-cms-widget-markdown/src/MarkdownPreview.js @@ -10,43 +10,14 @@ class MarkdownPreview extends React.Component { value: PropTypes.string, }; - subscribed = true; - - state = { - html: null, - }; - - async _renderHtml() { - const { value, getAsset, resolveWidget } = this.props; - if (value) { - const html = await markdownToHtml(value, { getAsset, resolveWidget }); - if (this.subscribed) { - this.setState({ html }); - } - } - } - - componentDidMount() { - this._renderHtml(); - } - - componentDidUpdate(prevProps) { - if (prevProps.value !== this.props.value || prevProps.getAsset !== this.props.getAsset) { - this._renderHtml(); - } - } - - componentWillUnmount() { - this.subscribed = false; - } - render() { - const { html } = this.state; - - if (html === null) { + const { value, getAsset, resolveWidget } = this.props; + if (value === null) { return null; } + const html = markdownToHtml(value, { getAsset, resolveWidget }); + return ; } } diff --git a/packages/netlify-cms-widget-markdown/src/serializers/index.js b/packages/netlify-cms-widget-markdown/src/serializers/index.js index 45eb332f..3f0be220 100644 --- a/packages/netlify-cms-widget-markdown/src/serializers/index.js +++ b/packages/netlify-cms-widget-markdown/src/serializers/index.js @@ -148,13 +148,13 @@ export const remarkToMarkdown = obj => { /** * Convert Markdown to HTML. */ -export const markdownToHtml = async (markdown, { getAsset, resolveWidget } = {}) => { +export const markdownToHtml = (markdown, { getAsset, resolveWidget } = {}) => { const mdast = markdownToRemark(markdown); - const hast = await unified() + const hast = unified() .use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset, resolveWidget }) .use(remarkToRehype, { allowDangerousHTML: true }) - .run(mdast); + .runSync(mdast); const html = unified() .use(rehypeToHtml, { diff --git a/packages/netlify-cms-widget-markdown/src/serializers/remarkRehypeShortcodes.js b/packages/netlify-cms-widget-markdown/src/serializers/remarkRehypeShortcodes.js index f7bb625f..67d612aa 100644 --- a/packages/netlify-cms-widget-markdown/src/serializers/remarkRehypeShortcodes.js +++ b/packages/netlify-cms-widget-markdown/src/serializers/remarkRehypeShortcodes.js @@ -12,15 +12,15 @@ import u from 'unist-builder'; export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWidget }) { return transform; - async function transform(root) { - const transformedChildren = await Promise.all(map(root.children, processShortcodes)); + function transform(root) { + const transformedChildren = map(root.children, processShortcodes); return { ...root, children: transformedChildren }; } /** * Mapping function to transform nodes that contain shortcodes. */ - async function processShortcodes(node) { + function processShortcodes(node) { /** * If the node doesn't contain shortcode data, return the original node. */ @@ -38,7 +38,7 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWid * an HTML string or a React component. If a React component is returned, * render it to an HTML string. */ - const value = await getPreview(plugin, shortcodeData); + const value = getPreview(plugin, shortcodeData); const valueHtml = typeof value === 'string' ? value : renderToString(value); /** @@ -52,7 +52,7 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWid /** * Retrieve the shortcode preview component. */ - async function getPreview(plugin, shortcodeData) { + function getPreview(plugin, shortcodeData) { const { toPreview, widget, fields } = plugin; if (toPreview) { return toPreview(shortcodeData, getAsset, fields); diff --git a/packages/netlify-cms-widget-relation/src/RelationControl.js b/packages/netlify-cms-widget-relation/src/RelationControl.js index ffd353f5..bf63629a 100644 --- a/packages/netlify-cms-widget-relation/src/RelationControl.js +++ b/packages/netlify-cms-widget-relation/src/RelationControl.js @@ -114,7 +114,7 @@ export default class RelationControl extends React.Component { }; parseNestedFields = (targetObject, field) => { - let nestedField = field.split('.'); + const nestedField = field.split('.'); let f = targetObject; for (let i = 0; i < nestedField.length; i++) { f = f[nestedField[i]]; diff --git a/setupTestFramework.js b/setupTestFramework.js index 01aebfe4..5df44bc0 100644 --- a/setupTestFramework.js +++ b/setupTestFramework.js @@ -5,4 +5,5 @@ import * as emotion from 'emotion'; import { createSerializer } from 'jest-emotion'; window.fetch = fetch; +window.URL.createObjectURL = jest.fn(); expect.addSnapshotSerializer(createSerializer(emotion)); diff --git a/website/content/docs/architecture.md b/website/content/docs/architecture.md index 26f37de2..7255c291 100755 --- a/website/content/docs/architecture.md +++ b/website/content/docs/architecture.md @@ -59,7 +59,7 @@ The control component receives one (1) callback as a prop: `onChange`. * onChange (required): Should be called when the users changes the current value. It will ultimately end up updating the EntryDraft object in the Redux Store, thus updating the preview component. * onAddAsset & onRemoveAsset (optionals): Should be invoked with an `AssetProxy` value object if the field accepts file uploads for media (images, for example). `onAddAsset` will get the current media stored in the Redux state tree while `onRemoveAsset` will remove it. AssetProxy objects are stored in the `Medias` object and referenced in the `EntryDraft` object on the state tree. -Both control and preview widgets receive a `getAsset` selector via props. Displaying the media (or its URI) for the user should always be done via `getAsset`, as it returns a Promise that resolves to an AssetProxy that can return the correct value for both medias already persisted on the server and cached media not yet uploaded. +Both control and preview widgets receive a `getAsset` selector via props. Displaying the media (or its URI) for the user should always be done via `getAsset`, as it returns an AssetProxy that can return the correct value for both medias already persisted on the server and cached media not yet uploaded. The actual persistence of the content and medias inserted into the control component is delegated to the backend implementation. The backend will be called with the updated values and a list of assetProxy objects for each field of the entry, and should return a promise that can resolve into the persisted entry object and the list of the persisted media URIs. diff --git a/website/content/docs/customization.md b/website/content/docs/customization.md index 6f8f7ec0..1a4a57a0 100644 --- a/website/content/docs/customization.md +++ b/website/content/docs/customization.md @@ -72,53 +72,19 @@ Registers a template for a folder collection or an individual file in a file col ``` ### Lists and Objects @@ -212,4 +178,4 @@ Registers a template for a folder collection or an individual file in a file col } } - ``` + ``` \ No newline at end of file