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
30 changed files with 317 additions and 355 deletions

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