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
This commit is contained in:
Erez Rokah 2020-02-13 02:12:36 +02:00 committed by GitHub
parent 2457c29baf
commit ab685e8594
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 317 additions and 355 deletions

View File

@ -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: {

View File

@ -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([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], '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);
});
});
});

View File

@ -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<State, {}, AnyAction>, 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([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
type: 'image/svg+xml',
}),
});
export function boundGetAsset(
dispatch: ThunkDispatch<State, {}, AnyAction>,
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<State, {}, AnyAction>, getState: () => State) => {
if (!path) return createAssetProxy({ path: '', file: new File([], 'empty') });
return (dispatch: ThunkDispatch<State, {}, AnyAction>, 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;
};
}

View File

@ -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 <Asset folder={folder} path={image} getAsset={getAsset} component={CardImage} />;
};
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 (
<GridCard>
@ -116,9 +116,7 @@ const EntryCard = ({
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
<CardHeading>{summary}</CardHeading>
</CardBody>
{image ? (
<CardImageAsset getAsset={boundGetAsset} image={image} folder={imageFolder} />
) : null}
{image ? <CardImage src={src} /> : null}
</GridCardLink>
</GridCard>
);
@ -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),
};
};

View File

@ -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 {
<EditorInterface
draftKey={draftKey}
entry={entryDraft.get('entry')}
getAsset={boundGetAsset}
collection={collection}
fields={fields}
fieldsMetaData={entryDraft.get('fieldsMetaData')}
@ -472,6 +468,7 @@ function mapStateToProps(state, ownProps) {
const deployPreview = selectDeployPreview(state, collectionName, slug);
const localBackup = entryDraft.get('localBackup');
const draftKey = entryDraft.get('key');
return {
collection,
collections,
@ -515,25 +512,6 @@ const mapDispatchToProps = {
unpublishPublishedEntry,
deleteUnpublishedEntry,
logoutUser,
boundGetAsset: (collection, entry) => (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)));

View File

@ -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) => {

View File

@ -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}
/>
</PreviewPaneContainer>
</StyledSplitPane>
@ -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,

View File

@ -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);

View File

@ -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
);
}

View File

@ -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 } }),
);
});
});

View File

@ -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;

View File

@ -1,5 +1,5 @@
import { createHashHistory } from 'history';
let history = createHashHistory();
const history = createHashHistory();
export default history;

View File

@ -144,7 +144,9 @@ export type Collection = StaticallyTypedRecord<CollectionObject>;
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<string> {
payload: string | AssetProxy | AssetProxy[];
payload: string | AssetProxy | AssetProxy[] | { path: string } | { path: string; error: Error };
}
export interface ConfigAction extends Action<string> {

View File

@ -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 },
});
});

View File

@ -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 <img src={src || ''} alt={alt || ''} title={title || ''} />;
},
pattern: /^!\[(.*)\]\((.*?)(\s"(.*)")?\)$/,

View File

@ -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;

View File

@ -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,
};

View File

@ -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 }) => (
<a href={href} rel="noopener noreferrer" target="_blank">
{path}
</a>
@ -12,25 +12,24 @@ const FileLink = styled(({ value: href, path }) => (
display: block;
`;
const FileLinkAsset = ({ value, getAsset }) => {
return <Asset path={value} getAsset={getAsset} component={FileLink} />;
};
function FileLinkList({ values, getAsset }) {
function FileLinkList({ values, getAsset, folder }) {
return (
<div>
{values.map(value => (
<FileLinkAsset key={value} value={value} getAsset={getAsset} />
<FileLink key={value} path={value} href={getAsset(value, folder)} />
))}
</div>
);
}
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 <FileLinkList values={value} getAsset={getAsset} />;
return <FileLinkList values={value} getAsset={getAsset} folder={folder} />;
}
return <FileLinkAsset value={value} getAsset={getAsset} />;
return <FileLink key={value} path={value} href={getAsset(value, folder)} />;
}
const FilePreview = props => (

View File

@ -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 }) => <img src={src || ''} role="presentation" />)`
const StyledImage = styled.img`
width: 100%;
height: 100%;
object-fit: contain;
`;
const ImageAsset = ({ getAsset, value, folder }) => {
return <Asset folder={folder} path={value} getAsset={getAsset} component={Image} />;
};
const Image = props => <StyledImage role="presentation" {...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 } = {}) {
<MultiImageWrapper>
{value.map(val => (
<ImageWrapper key={val}>
<ImageAsset getAsset={getAsset} value={val} folder={folder} />
<Image src={getAsset(val, folder) || ''} />
</ImageWrapper>
))}
</MultiImageWrapper>
);
}
const src = getAsset(value, folder);
return (
<ImageWrapper>
<ImageAsset getAsset={getAsset} value={value} folder={folder} />
<Image src={src || ''} />
</ImageWrapper>
);
};

View File

@ -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 }) => <img src={src || ''} role="presentation" />)`
const StyledImage = styled(({ src }) => <img src={src || ''} role="presentation" />)`
display: block;
max-width: 100%;
height: auto;
`;
const StyledImageAsset = ({ getAsset, value, field }) => {
return (
<Asset
folder={field.get('media_folder')}
path={value}
getAsset={getAsset}
component={StyledImage}
/>
);
return <StyledImage src={getAsset(value, field.get('media_folder'))} />;
};
const ImagePreviewContent = props => {

View File

@ -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));
};

View File

@ -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();
};

View File

@ -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);

View File

@ -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 <WidgetPreviewContainer dangerouslySetInnerHTML={{ __html: html }} />;
}
}

View File

@ -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, {

View File

@ -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);

View File

@ -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]];

View File

@ -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));

View File

@ -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.

View File

@ -72,53 +72,19 @@ Registers a template for a folder collection or an individual file in a file col
<script src="https://unpkg.com/netlify-cms@^2.0.0/dist/netlify-cms.js"></script>
<script>
var PostPreview = createClass({
subscribed: true,
getInitialState: function() {
return {
src: '',
};
},
_fetchAsset: function() {
const path = this.props.entry.getIn(['data', 'image']);
path &&
this.props.getAsset(path).then(value => {
if (this.subscribed) {
this.setState({ src: value.toString() });
}
});
},
componentDidMount: function() {
this._fetchAsset();
},
componentWillUnmount: function() {
this.subscribed = false;
},
componentDidUpdate: function(prevProps) {
const prevPath = prevProps.entry.getIn(['data', 'image']);
const path = this.props.entry.getIn(['data', 'image']);
if (prevPath !== path || prevProps.getAsset !== this.props.getAsset) {
this._fetchAsset();
}
},
render: function() {
var entry = this.props.entry;
return h(
'div',
{},
var image = entry.getIn(['data', 'image']);
var bg = this.props.getAsset(image);
return h('div', {},
h('h1', {}, entry.getIn(['data', 'title'])),
h('img', { src: this.state.src }),
h('div', { className: 'text' }, this.props.widgetFor('body')),
h('img', {src: bg.toString()}),
h('div', {"className": "text"}, this.props.widgetFor('body'))
);
},
}
});
CMS.registerPreviewTemplate('posts', PostPreview);
CMS.registerPreviewTemplate("posts", PostPreview);
</script>
```
### Lists and Objects
@ -212,4 +178,4 @@ Registers a template for a folder collection or an individual file in a file col
</article>
}
}
```
```