Allow for relative paths of media files (#2394)

* Allow for relative paths of media files

fixes #325

* Switch to calculating the relative path based on collection

The required relative path is now calculated depending on the
location of the collection of the current entry having the
media inserted into. And the configuration option has now been
changed to a boolean flag.

This allows collections to not neccesarily all be in the same
location relative to the media folder, and simplifies config.

* Clean up code and fix linting

* Add unit tests to resolveMediaFilename()

* Rework insertMedia action to fetch own config

This moves more of the media path resolution logic into the action
which makes it easier to unit test

* Add unit tests for the mediaLibrary.insertMedia action

* yarn run format

* add dependabot config (#2580)
This commit is contained in:
Sam Lanning 2019-08-24 21:03:09 +01:00 committed by Shawn Erquhart
parent 861c36f658
commit a47a29fb8b
10 changed files with 279 additions and 10 deletions

View File

@ -78,5 +78,8 @@
"react": "^16.8.4",
"react-dom": "^16.8.4",
"react-immutable-proptypes": "^2.1.0"
},
"devDependencies": {
"redux-mock-store": "^1.5.3"
}
}

View File

@ -0,0 +1,94 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fromJS } from 'immutable';
import { insertMedia } from '../mediaLibrary';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('mediaLibrary', () => {
describe('insertMedia', () => {
it('test public URL is returned directly', () => {
const store = mockStore({});
store.dispatch(insertMedia({ url: '//localhost/foo.png' }));
expect(store.getActions()[0]).toEqual({
type: 'MEDIA_INSERT',
payload: { mediaPath: '//localhost/foo.png' },
});
});
it('Test relative path resolution', () => {
const store = mockStore({
config: fromJS({
media_folder_relative: true,
media_folder: 'content/media',
}),
entryDraft: fromJS({
entry: {
collection: 'blog-posts',
},
}),
collections: fromJS({
'blog-posts': {
folder: 'content/blog/posts',
},
}),
});
store.dispatch(insertMedia({ name: 'foo.png' }));
expect(store.getActions()[0]).toEqual({
type: 'MEDIA_INSERT',
payload: { mediaPath: '../../media/foo.png' },
});
});
// media_folder_relative will be used even if public_folder is specified
it('Test relative path resolution, with public folder specified', () => {
const store = mockStore({
config: fromJS({
media_folder_relative: true,
media_folder: 'content/media',
public_folder: '/static/assets/media',
}),
entryDraft: fromJS({
entry: {
collection: 'blog-posts',
},
}),
collections: fromJS({
'blog-posts': {
folder: 'content/blog/posts',
},
}),
});
store.dispatch(insertMedia({ name: 'foo.png' }));
expect(store.getActions()[0]).toEqual({
type: 'MEDIA_INSERT',
payload: { mediaPath: '../../media/foo.png' },
});
});
it('Test public_folder resolution', () => {
const store = mockStore({
config: fromJS({
public_folder: '/static/assets/media',
}),
});
store.dispatch(insertMedia({ name: 'foo.png' }));
expect(store.getActions()[0]).toEqual({
type: 'MEDIA_INSERT',
payload: { mediaPath: '/static/assets/media/foo.png' },
});
});
it('Test incorrect usage', () => {
const store = mockStore();
try {
store.dispatch(insertMedia({ foo: 'foo.png' }));
throw new Error('Expected Exception');
} catch (e) {
expect(e.message).toEqual('Incorrect usage, expected {url} or {file}');
}
});
});
});

View File

@ -1,6 +1,6 @@
import { Map } from 'immutable';
import { actions as notifActions } from 'redux-notifications';
import { getBlobSHA } from 'netlify-cms-lib-util';
import { resolveMediaFilename, getBlobSHA } from 'netlify-cms-lib-util';
import { currentBackend } from 'coreSrc/backend';
import { createAssetProxy } from 'ValueObjects/AssetProxy';
import { selectIntegration } from 'Reducers';
@ -82,8 +82,33 @@ export function closeMediaLibrary() {
};
}
export function insertMedia(mediaPath) {
return { type: MEDIA_INSERT, payload: { mediaPath } };
export function insertMedia(media) {
return (dispatch, getState) => {
let mediaPath;
if (media.url) {
// media.url is public, and already resolved
mediaPath = media.url;
} else if (media.name) {
// media.name still needs to be resolved to the appropriate URL
const state = getState();
const config = state.config;
if (config.get('media_folder_relative')) {
// the path is being resolved relatively
// and we need to know the path of the entry to resolve it
const mediaFolder = config.get('media_folder');
const collection = state.entryDraft.getIn(['entry', 'collection']);
const collectionFolder = state.collections.getIn([collection, 'folder']);
mediaPath = resolveMediaFilename(media.name, { mediaFolder, collectionFolder });
} else {
// the path is being resolved to a public URL
const publicFolder = config.get('public_folder');
mediaPath = resolveMediaFilename(media.name, { publicFolder });
}
} else {
throw new Error('Incorrect usage, expected {url} or {file}');
}
dispatch({ type: MEDIA_INSERT, payload: { mediaPath } });
};
}
export function removeInsertedMedia(controlID) {

View File

@ -5,7 +5,7 @@ import { connect } from 'react-redux';
import { orderBy, map } from 'lodash';
import { translate } from 'react-polyglot';
import fuzzy from 'fuzzy';
import { resolvePath, fileExtension } from 'netlify-cms-lib-util';
import { fileExtension } from 'netlify-cms-lib-util';
import {
loadMedia as loadMediaAction,
persistMedia as persistMediaAction,
@ -56,7 +56,6 @@ class MediaLibrary extends React.Component {
persistMedia: PropTypes.func.isRequired,
deleteMedia: PropTypes.func.isRequired,
insertMedia: PropTypes.func.isRequired,
publicFolder: PropTypes.string,
closeMediaLibrary: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
@ -189,9 +188,8 @@ class MediaLibrary extends React.Component {
handleInsert = () => {
const { selectedFile } = this.state;
const { name, url, urlIsPublicPath } = selectedFile;
const { insertMedia, publicFolder } = this.props;
const publicPath = urlIsPublicPath ? url : resolvePath(name, publicFolder);
insertMedia(publicPath);
const { insertMedia } = this.props;
insertMedia(urlIsPublicPath ? { url } : { name });
this.handleClose();
};

View File

@ -40,6 +40,7 @@ const getConfigSchema = () => ({
show_preview_links: { type: 'boolean' },
media_folder: { type: 'string', examples: ['assets/uploads'] },
public_folder: { type: 'string', examples: ['/uploads'] },
media_folder_relative: { type: 'boolean' },
media_library: {
type: 'object',
properties: {

View File

@ -17,6 +17,7 @@
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward"
},
"dependencies": {
"get-relative-path": "^1.0.2",
"js-sha256": "^0.9.0",
"localforage": "^1.7.3"
},

View File

@ -1,4 +1,93 @@
import { fileExtensionWithSeparator, fileExtension } from '../path';
import { resolveMediaFilename, fileExtensionWithSeparator, fileExtension } from '../path';
describe('resolveMediaFilename', () => {
it('publicly Accessible URL, no slash', () => {
expect(
resolveMediaFilename('image.png', {
publicFolder: 'static/assets',
}),
).toEqual('/static/assets/image.png');
});
it('publicly Accessible URL, with slash', () => {
expect(
resolveMediaFilename('image.png', {
publicFolder: '/static/assets',
}),
).toEqual('/static/assets/image.png');
});
it('publicly Accessible URL, root', () => {
expect(
resolveMediaFilename('image.png', {
publicFolder: '/',
}),
).toEqual('/image.png');
});
it('relative URL, same folder', () => {
expect(
resolveMediaFilename('image.png', {
mediaFolder: '/content/posts',
collectionFolder: '/content/posts',
}),
).toEqual('image.png');
});
it('relative URL, same folder, with slash', () => {
expect(
resolveMediaFilename('image.png', {
mediaFolder: '/content/posts/',
collectionFolder: '/content/posts',
}),
).toEqual('image.png');
});
it('relative URL, same folder, with slashes', () => {
expect(
resolveMediaFilename('image.png', {
mediaFolder: '/content/posts/',
collectionFolder: '/content/posts/',
}),
).toEqual('image.png');
});
it('relative URL, sibling folder', () => {
expect(
resolveMediaFilename('image.png', {
mediaFolder: '/content/images/',
collectionFolder: '/content/posts/',
}),
).toEqual('../images/image.png');
});
it('relative URL, cousin folder', () => {
expect(
resolveMediaFilename('image.png', {
mediaFolder: '/content/images/pngs/',
collectionFolder: '/content/markdown/posts/',
}),
).toEqual('../../images/pngs/image.png');
});
it('relative URL, parent folder', () => {
expect(
resolveMediaFilename('image.png', {
mediaFolder: '/content/',
collectionFolder: '/content/posts',
}),
).toEqual('../image.png');
});
it('relative URL, child folder', () => {
expect(
resolveMediaFilename('image.png', {
mediaFolder: '/content/images',
collectionFolder: '/content/',
}),
).toEqual('images/image.png');
});
});
describe('fileExtensionWithSeparator', () => {
it('should return the extension of a file', () => {

View File

@ -2,7 +2,13 @@ import APIError from './APIError';
import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from './Cursor';
import EditorialWorkflowError, { EDITORIAL_WORKFLOW_ERROR } from './EditorialWorkflowError';
import localForage from './localForage';
import { resolvePath, basename, fileExtensionWithSeparator, fileExtension } from './path';
import {
resolvePath,
resolveMediaFilename,
basename,
fileExtensionWithSeparator,
fileExtension,
} from './path';
import {
filterPromises,
filterPromisesWith,
@ -29,6 +35,7 @@ export const NetlifyCmsLibUtil = {
EDITORIAL_WORKFLOW_ERROR,
localForage,
resolvePath,
resolveMediaFilename,
basename,
fileExtensionWithSeparator,
fileExtension,
@ -53,6 +60,7 @@ export {
EDITORIAL_WORKFLOW_ERROR,
localForage,
resolvePath,
resolveMediaFilename,
basename,
fileExtensionWithSeparator,
fileExtension,

View File

@ -1,3 +1,5 @@
import getRelativePath from 'get-relative-path';
const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i');
const normalizePath = path => path.replace(/[\\/]+/g, '/');
@ -17,6 +19,37 @@ export function resolvePath(path, basePath) {
return normalizePath(`/${path}`);
}
/**
* Take a media filename and resolve it with respect to a
* certain collection entry, either as an absolute URL, or
* a path relative to the collection entry's folder.
*
* @param {*} filename the filename of the media item within the media_folder
* @param {*} options how the filename should be resolved, see examples below:
*
* @example Resolving to publicly accessible URL
* mediaFilenameToUse('image.jpg', {
* publicFolder: '/static/assets' // set by public_folder
* }) // -> "/static/assets/image.jpg"
*
* @example Resolving URL relatively to a specific collection entry
* mediaFilenameToUse('image.jpg', {
* mediaFolder: '/content/media', // set by media_folder
* collectionFolder: 'content/posts'
* }) // -> "../media/image.jpg"
*
*/
export function resolveMediaFilename(filename, options) {
if (options.publicFolder) {
return resolvePath(filename, options.publicFolder);
} else if (options.mediaFolder && options.collectionFolder) {
const media = normalizePath(`/${options.mediaFolder}/${filename}`);
const collection = normalizePath(`/${options.collectionFolder}/`);
return getRelativePath(collection, media);
}
throw new Error('incorrect usage');
}
/**
* Return the last portion of a path. Similar to the Unix basename command.
* @example Usage example

View File

@ -5695,6 +5695,11 @@ get-port@^3.2.0:
resolved "https://registry.yarnpkg.com/get-port/-/get-port-3.2.0.tgz#dd7ce7de187c06c8bf353796ac71e099f0980ebc"
integrity sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=
get-relative-path@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/get-relative-path/-/get-relative-path-1.0.2.tgz#45c26fc4247f0c8541cec4b57d0de993ec965cda"
integrity sha512-dGkopYfmB4sXMTcZslq5SojEYakpdCSj/SVSHLhv7D6RBHzvDtd/3Q8lTEOAhVKxPPeAHu/YYkENbbz3PaH+8w==
get-stdin@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
@ -7902,6 +7907,11 @@ lodash.ismatch@^4.4.0:
resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37"
integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
lodash.once@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
@ -10469,6 +10479,13 @@ redent@^2.0.0:
indent-string "^3.0.0"
strip-indent "^2.0.0"
redux-mock-store@^1.5.3:
version "1.5.3"
resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.5.3.tgz#1f10528949b7ce8056c2532624f7cafa98576c6d"
integrity sha512-ryhkkb/4D4CUGpAV2ln1GOY/uh51aczjcRz9k2L2bPx/Xja3c5pSGJJPyR25GNVRXtKIExScdAgFdiXp68GmJA==
dependencies:
lodash.isplainobject "^4.0.6"
redux-notifications@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/redux-notifications/-/redux-notifications-4.0.1.tgz#66c9f11bb1eb375c633beaaf7378005eab303bfb"