Fix: show specific field media files in library, cascade folder templates (#3252)

* feat: cascade & compose media folders - initial commit

* refactor: code cleanup

* fix: pass field instead of folder to getAsset

* fix: only show field media files in library

* test: fix medial library selector test

* fix: fallback to original path when asset not found

* fix: only show field media files in media library

* fix: properly handle empty strings in field folders
This commit is contained in:
Erez Rokah 2020-02-14 22:31:33 +02:00 committed by GitHub
parent 8d67de0e68
commit 02ef2010e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 565 additions and 243 deletions

View File

@ -105,5 +105,26 @@ describe('media', () => {
});
expect(result).toEqual(emptyAsset);
});
it('should return asset with original path on load error', () => {
const path = 'static/media/image.png';
const store = mockStore({
medias: Map({ [path]: { error: true } }),
});
selectMediaFilePath.mockReturnValue(path);
const payload = { path };
const result = store.dispatch(getAsset(payload));
const actions = store.getActions();
const asset = new AssetProxy({ url: path, path });
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
type: ADD_ASSET,
payload: asset,
});
expect(result).toEqual(asset);
});
});
});

View File

@ -297,14 +297,14 @@ export function retrieveLocalBackup(collection: Collection, slug: string) {
path: file.path,
file: file.file,
url: file.url,
folder: file.folder,
field: file.field,
});
} else {
return getAsset({
collection,
entry: fromJS(entry),
path: file.path,
folder: file.folder,
field: file.field,
})(dispatch, getState);
}
}),
@ -557,12 +557,15 @@ export async function getMediaAssets({
entry: EntryMap;
dispatch: Dispatch;
}) {
const filesArray = entry.get('mediaFiles').toJS() as MediaFile[];
const filesArray = entry.get('mediaFiles').toArray();
const assets = await Promise.all(
filesArray
.filter(file => file.draft)
.filter(file => file.get('draft'))
.map(file =>
getAsset({ collection, entry, path: file.path, folder: file.folder })(dispatch, getState),
getAsset({ collection, entry, path: file.get('path'), field: file.get('field') })(
dispatch,
getState,
),
),
);

View File

@ -1,5 +1,5 @@
import AssetProxy, { createAssetProxy } from '../valueObjects/AssetProxy';
import { Collection, State, EntryMap } from '../types/redux';
import { Collection, State, EntryMap, EntryField } from '../types/redux';
import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux';
import { isAbsolutePath } from 'netlify-cms-lib-util';
@ -67,7 +67,7 @@ interface GetAssetArgs {
collection: Collection;
entry: EntryMap;
path: string;
folder?: string;
field?: EntryField;
}
const emptyAsset = createAssetProxy({
@ -82,26 +82,27 @@ export function boundGetAsset(
collection: Collection,
entry: EntryMap,
) {
const bound = (path: string, folder: string) => {
const asset = dispatch(getAsset({ collection, entry, path, folder }));
const bound = (path: string, field: EntryField) => {
const asset = dispatch(getAsset({ collection, entry, path, field }));
return asset;
};
return bound;
}
export function getAsset({ collection, entry, path, folder }: GetAssetArgs) {
export function getAsset({ collection, entry, path, field }: GetAssetArgs) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
if (!path) return emptyAsset;
const state = getState();
const resolvedPath = selectMediaFilePath(state.config, collection, entry, path, folder);
const resolvedPath = selectMediaFilePath(state.config, collection, entry, path, field);
let { asset, isLoading, error } = state.medias.get(resolvedPath) || {};
if (isLoading) {
return emptyAsset;
}
if (asset && !error) {
if (asset) {
// There is already an AssetProxy in memory for this path. Use it.
return asset;
}
@ -111,8 +112,14 @@ export function getAsset({ collection, entry, path, folder }: GetAssetArgs) {
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
} else {
dispatch(loadAsset(resolvedPath));
asset = emptyAsset;
if (error) {
// on load error default back to original path
asset = createAssetProxy({ path, url: path });
dispatch(addAsset(asset));
} else {
dispatch(loadAsset(resolvedPath));
asset = emptyAsset;
}
}
return asset;

View File

@ -14,7 +14,13 @@ import { getIntegrationProvider } from '../integrations';
import { addAsset, removeAsset } from './media';
import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries';
import { sanitizeSlug } from '../lib/urlHelper';
import { State, MediaFile, DisplayURLState, MediaLibraryInstance } from '../types/redux';
import {
State,
MediaFile,
DisplayURLState,
MediaLibraryInstance,
EntryField,
} from '../types/redux';
import { AnyAction } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { waitUntilWithTimeout } from './waitUntil';
@ -103,7 +109,7 @@ export function closeMediaLibrary() {
};
}
export function insertMedia(mediaPath: string | string[], publicFolder: string | undefined) {
export function insertMedia(mediaPath: string | string[], field: EntryField | undefined) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const config = state.config;
@ -112,16 +118,10 @@ export function insertMedia(mediaPath: string | string[], publicFolder: string |
const collection = state.collections.get(collectionName);
if (Array.isArray(mediaPath)) {
mediaPath = mediaPath.map(path =>
selectMediaFilePublicPath(config, collection, path, entry, publicFolder),
selectMediaFilePublicPath(config, collection, path, entry, field),
);
} else {
mediaPath = selectMediaFilePublicPath(
config,
collection,
mediaPath as string,
entry,
publicFolder,
);
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry, field);
}
dispatch({ type: MEDIA_INSERT, payload: { mediaPath } });
};
@ -201,18 +201,18 @@ function createMediaFileFromAsset({
size: file.size,
url: assetProxy.url,
path: assetProxy.path,
folder: assetProxy.folder,
field: assetProxy.field,
};
return mediaFile;
}
export function persistMedia(file: File, opts: MediaOptions = {}) {
const { privateUpload, mediaFolder } = opts;
const { privateUpload, field } = opts;
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, null, 'assetStore');
const files: MediaFile[] = selectMediaFiles(state);
const files: MediaFile[] = selectMediaFiles(state, field);
const fileName = sanitizeSlug(file.name.toLowerCase(), state.config.get('slug'));
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
@ -261,11 +261,11 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
} else {
const entry = state.entryDraft.get('entry');
const collection = state.collections.get(entry?.get('collection'));
const path = selectMediaFilePath(state.config, collection, entry, file.name, mediaFolder);
const path = selectMediaFilePath(state.config, collection, entry, file.name, field);
assetProxy = createAssetProxy({
file,
path,
folder: mediaFolder,
field,
});
}
@ -358,12 +358,8 @@ export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) {
export async function getMediaFile(state: State, path: string) {
const backend = currentBackend(state.config);
try {
const { url } = await backend.getMediaFile(path);
return { url };
} catch (e) {
return { url: path };
}
const { url } = await backend.getMediaFile(path);
return { url };
}
export function loadMediaDisplayURL(file: MediaFile) {
@ -409,7 +405,7 @@ export function mediaLoading(page: number) {
interface MediaOptions {
privateUpload?: boolean;
mediaFolder?: string;
field?: EntryField;
}
export function mediaLoaded(files: ImplementationMediaFile[], opts: MediaOptions = {}) {

View File

@ -27,7 +27,6 @@ import {
Implementation as BackendImplementation,
DisplayURL,
ImplementationEntry,
ImplementationMediaFile,
Credentials,
User,
getPathDepth,
@ -45,6 +44,7 @@ import {
EntryDraft,
CollectionFile,
State,
EntryField,
} from './types/redux';
import AssetProxy from './valueObjects/AssetProxy';
import { FOLDER, FILES } from './constants/collectionTypes';
@ -104,10 +104,22 @@ interface BackendOptions {
config?: Config;
}
export interface MediaFile {
name: string;
id: string;
size?: number;
displayURL?: DisplayURL;
path: string;
draft?: boolean;
url?: string;
file?: File;
field?: EntryField;
}
interface BackupEntry {
raw: string;
path: string;
mediaFiles: ImplementationMediaFile[];
mediaFiles: MediaFile[];
}
interface PersistArgs {
@ -444,11 +456,11 @@ export class Backend {
return;
}
const mediaFiles = await Promise.all<ImplementationMediaFile>(
const mediaFiles = await Promise.all<MediaFile>(
entry
.get('mediaFiles')
.toJS()
.map(async (file: ImplementationMediaFile) => {
.map(async (file: MediaFile) => {
// make sure to serialize the file
if (file.url?.startsWith('blob:')) {
const blob = await fetch(file.url as string).then(res => res.blob());
@ -485,7 +497,6 @@ export class Backend {
const integration = selectIntegration(state.integrations, null, 'assetStore');
const loadedEntry = await this.implementation.getEntry(path);
const entry = createEntry(collection.get('name'), slug, loadedEntry.file.path, {
raw: loadedEntry.data,
label,
@ -700,7 +711,7 @@ export class Backend {
collection,
entryDraft.get('entry').set('path', path),
oldPath,
asset.folder,
asset.field,
);
asset.path = newPath;
});

View File

@ -89,7 +89,7 @@ const EntryCard = ({
path,
summary,
image,
imageFolder,
imageField,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
getAsset,
@ -105,7 +105,7 @@ const EntryCard = ({
);
}
const asset = getAsset(image, imageFolder);
const asset = getAsset(image, imageField);
const src = asset.toString();
if (viewStyle === VIEW_STYLE_GRID) {
@ -148,8 +148,7 @@ const mapStateToProps = (state, ownProps) => {
image,
imageFolder: collection
.get('fields')
?.find(f => f.get('name') === inferedFields.imageField && f.get('widget') === 'image')
?.get('media_folder'),
?.find(f => f.get('name') === inferedFields.imageField && f.get('widget') === 'image'),
isLoadingAsset,
};
};

View File

@ -160,7 +160,7 @@ class MediaLibrary extends React.Component {
event.persist();
event.stopPropagation();
event.preventDefault();
const { persistMedia, privateUpload, config, t, mediaFolder } = this.props;
const { persistMedia, privateUpload, config, t, field } = this.props;
const { files: fileList } = event.dataTransfer || event.target;
const files = [...fileList];
const file = files[0];
@ -173,7 +173,7 @@ class MediaLibrary extends React.Component {
}),
);
} else {
await persistMedia(file, { privateUpload, mediaFolder });
await persistMedia(file, { privateUpload, field });
this.setState({ selectedFile: this.props.files[0] });
@ -190,8 +190,8 @@ class MediaLibrary extends React.Component {
handleInsert = () => {
const { selectedFile } = this.state;
const { path } = selectedFile;
const { insertMedia, publicFolder } = this.props;
insertMedia(path, publicFolder);
const { insertMedia, field } = this.props;
insertMedia(path, field);
this.handleClose();
};
@ -315,10 +315,11 @@ class MediaLibrary extends React.Component {
const mapStateToProps = state => {
const { mediaLibrary } = state;
const field = mediaLibrary.get('field');
const mediaLibraryProps = {
isVisible: mediaLibrary.get('isVisible'),
canInsert: mediaLibrary.get('canInsert'),
files: selectMediaFiles(state),
files: selectMediaFiles(state, field),
displayURLs: mediaLibrary.get('displayURLs'),
dynamicSearch: mediaLibrary.get('dynamicSearch'),
dynamicSearchActive: mediaLibrary.get('dynamicSearchActive'),
@ -332,8 +333,7 @@ const mapStateToProps = state => {
page: mediaLibrary.get('page'),
hasNextPage: mediaLibrary.get('hasNextPage'),
isPaginating: mediaLibrary.get('isPaginating'),
mediaFolder: mediaLibrary.get('mediaFolder'),
publicFolder: mediaLibrary.get('publicFolder'),
field,
};
return { ...mediaLibraryProps };
};

View File

@ -218,6 +218,7 @@ export const folderFormatter = (
if (!entry || !entry.get('data')) {
return folderTemplate;
}
let fields = (entry.get('data') as Map<string, string>).set(folderKey, defaultFolder);
fields = addFileTemplateFields(entry.get('path'), fields);
@ -232,5 +233,6 @@ export const folderFormatter = (
fields,
(value: string) => (value === defaultFolder ? defaultFolder : processSegment(value)),
);
return mediaFolder;
};

View File

@ -4,7 +4,7 @@ import collections, {
selectAllowDeletion,
selectEntryPath,
selectEntrySlug,
selectFieldsMediaFolders,
selectFieldsWithMediaFolders,
selectMediaFolders,
getFieldsNames,
selectField,
@ -87,12 +87,12 @@ describe('collections', () => {
describe('selectFieldsMediaFolders', () => {
it('should return empty array for invalid collection', () => {
expect(selectFieldsMediaFolders(fromJS({}))).toEqual([]);
expect(selectFieldsWithMediaFolders(fromJS({}))).toEqual([]);
});
it('should return configs for folder collection', () => {
expect(
selectFieldsMediaFolders(
selectFieldsWithMediaFolders(
fromJS({
folder: 'posts',
fields: [
@ -124,19 +124,26 @@ describe('collections', () => {
}),
),
).toEqual([
'image_media_folder',
'body_media_folder',
'list_1_item_media_folder',
'list_2_item_media_folder',
fromJS({
name: 'image',
media_folder: 'image_media_folder',
}),
fromJS({ name: 'body', media_folder: 'body_media_folder' }),
fromJS({ name: 'list_1_item', media_folder: 'list_1_item_media_folder' }),
fromJS({
name: 'list_2_item',
media_folder: 'list_2_item_media_folder',
}),
]);
});
it('should return configs for files collection', () => {
expect(
selectFieldsMediaFolders(
selectFieldsWithMediaFolders(
fromJS({
files: [
{
name: 'file1',
fields: [
{
name: 'image',
@ -145,6 +152,7 @@ describe('collections', () => {
],
},
{
name: 'file2',
fields: [
{
name: 'body',
@ -153,6 +161,7 @@ describe('collections', () => {
],
},
{
name: 'file3',
fields: [
{
name: 'list_1',
@ -164,6 +173,7 @@ describe('collections', () => {
],
},
{
name: 'file4',
fields: [
{
name: 'list_2',
@ -178,12 +188,13 @@ describe('collections', () => {
},
],
}),
'file4',
),
).toEqual([
'image_media_folder',
'body_media_folder',
'list_1_item_media_folder',
'list_2_item_media_folder',
fromJS({
name: 'list_2_item',
media_folder: 'list_2_item_media_folder',
}),
]);
});
});
@ -195,48 +206,53 @@ describe('collections', () => {
sanitize_replacement: '-',
};
const config = fromJS({ slug });
it('should return fields and collection folder', () => {
const config = fromJS({ slug, media_folder: '/static/img' });
it('should return fields and collection folders', () => {
expect(
selectMediaFolders(
{ config },
fromJS({
folder: 'posts',
media_folder: '/collection_media_folder',
media_folder: '{{media_folder}}/general/',
fields: [
{
name: 'image',
media_folder: '/image_media_folder',
media_folder: '{{media_folder}}/customers/',
},
],
}),
fromJS({ slug: 'name', path: 'src/post/post1.md' }),
fromJS({ slug: 'name', path: 'src/post/post1.md', data: {} }),
),
).toEqual(['collection_media_folder', 'image_media_folder']);
).toEqual(['static/img/general', 'static/img/general/customers']);
});
it('should return fields and collection folder', () => {
it('should return fields, file and collection folders', () => {
expect(
selectMediaFolders(
{ config },
fromJS({
media_folder: '{{media_folder}}/general/',
files: [
{
name: 'name',
file: 'src/post/post1.md',
media_folder: '/file_media_folder',
media_folder: '{{media_folder}}/customers/',
fields: [
{
name: 'image',
media_folder: '/image_media_folder',
media_folder: '{{media_folder}}/logos/',
},
],
},
],
}),
fromJS({ slug: 'name', path: 'src/post/post1.md' }),
fromJS({ slug: 'name', path: 'src/post/post1.md', data: {} }),
),
).toEqual(['file_media_folder', 'image_media_folder']);
).toEqual([
'static/img/general',
'static/img/general/customers',
'static/img/general/customers/logos',
]);
});
});

View File

@ -1,4 +1,4 @@
import { Map, OrderedMap, fromJS } from 'immutable';
import { OrderedMap, fromJS } from 'immutable';
import * as actions from 'Actions/entries';
import reducer, {
selectMediaFolder,
@ -7,13 +7,13 @@ import reducer, {
} from '../entries';
const initialState = OrderedMap({
posts: Map({ name: 'posts' }),
posts: fromJS({ name: 'posts' }),
});
describe('entries', () => {
describe('reducer', () => {
it('should mark entries as fetching', () => {
expect(reducer(initialState, actions.entriesLoading(Map({ name: 'posts' })))).toEqual(
expect(reducer(initialState, actions.entriesLoading(fromJS({ name: 'posts' })))).toEqual(
OrderedMap(
fromJS({
posts: { name: 'posts' },
@ -31,7 +31,7 @@ describe('entries', () => {
{ slug: 'b', title: 'B' },
];
expect(
reducer(initialState, actions.entriesLoaded(Map({ name: 'posts' }), entries, 0)),
reducer(initialState, actions.entriesLoaded(fromJS({ name: 'posts' }), entries, 0)),
).toEqual(
OrderedMap(
fromJS({
@ -53,7 +53,7 @@ describe('entries', () => {
it('should handle loaded entry', () => {
const entry = { slug: 'a', path: '' };
expect(reducer(initialState, actions.entryLoaded(Map({ name: 'posts' }), entry))).toEqual(
expect(reducer(initialState, actions.entryLoaded(fromJS({ name: 'posts' }), entry))).toEqual(
OrderedMap(
fromJS({
posts: { name: 'posts' },
@ -75,8 +75,8 @@ describe('entries', () => {
it("should return global media folder when collection doesn't specify media_folder", () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts' }),
fromJS({ media_folder: 'static/media' }),
fromJS({ name: 'posts' }),
undefined,
undefined,
),
@ -86,8 +86,8 @@ describe('entries', () => {
it('should return draft media folder when collection specifies media_folder and entry is undefined', () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
fromJS({ media_folder: 'static/media' }),
fromJS({ name: 'posts', folder: 'posts', media_folder: '' }),
undefined,
undefined,
),
@ -97,9 +97,9 @@ describe('entries', () => {
it('should return relative media folder when collection specifies media_folder and entry path is not null', () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
Map({ path: 'posts/title/index.md' }),
fromJS({ media_folder: 'static/media' }),
fromJS({ name: 'posts', folder: 'posts', media_folder: '' }),
fromJS({ path: 'posts/title/index.md' }),
undefined,
),
).toEqual('posts/title');
@ -108,24 +108,41 @@ describe('entries', () => {
it('should resolve collection relative media folder', () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '../' }),
Map({ path: 'posts/title/index.md' }),
fromJS({ media_folder: 'static/media' }),
fromJS({ name: 'posts', folder: 'posts', media_folder: '../' }),
fromJS({ path: 'posts/title/index.md' }),
undefined,
),
).toEqual('posts/');
).toEqual('posts');
});
it('should resolve field relative media folder', () => {
const field = fromJS({ media_folder: '' });
expect(
selectMediaFolder(
fromJS({ media_folder: '/static/img' }),
fromJS({
name: 'other',
folder: 'other',
fields: [field],
media_folder: '../',
}),
fromJS({ path: 'src/other/other.md', data: {} }),
field,
),
).toEqual('src/other');
});
it('should return collection absolute media folder without leading slash', () => {
expect(
selectMediaFolder(
Map({ media_folder: '/static/Images' }),
Map({
fromJS({ media_folder: '/static/Images' }),
fromJS({
name: 'getting-started',
folder: 'src/docs/getting-started',
media_folder: '/static/images/docs/getting-started',
}),
Map({ path: 'src/docs/getting-started/with-github.md' }),
fromJS({ path: 'src/docs/getting-started/with-github.md' }),
undefined,
),
).toEqual('static/images/docs/getting-started');
@ -201,7 +218,13 @@ describe('entries', () => {
const collection = fromJS({
name: 'posts',
folder: 'content',
fields: [{ name: 'title', widget: 'string' }],
fields: [
{
name: 'title',
widget: 'string',
media_folder: '../../../{{media_folder}}/{{category}}/{{slug}}',
},
],
});
expect(
@ -209,7 +232,7 @@ describe('entries', () => {
fromJS({ media_folder: 'static/media', slug: slugConfig }),
collection,
entry,
'../../../{{media_folder}}/{{category}}/{{slug}}',
collection.get('fields').get(0),
),
).toEqual('static/media/hosting-and-deployment/deployment-with-nanobox');
});
@ -260,7 +283,47 @@ describe('entries', () => {
fromJS({ path: 'posts/title/index.md', slug: 'index' }),
undefined,
),
).toBe('static/images/');
).toBe('static/images');
});
it('should cascade media_folders', () => {
const mainImageField = fromJS({ name: 'main_image' });
const logoField = fromJS({ name: 'logo', media_folder: '{{media_folder}}/logos/' });
const nestedField2 = fromJS({ name: 'nested', media_folder: '{{media_folder}}/nested2/' });
const nestedField1 = fromJS({
name: 'nested',
media_folder: '{{media_folder}}/nested1/',
fields: [nestedField2],
});
const args = [
fromJS({ media_folder: '/static/img' }),
fromJS({
name: 'general',
media_folder: '{{media_folder}}/general/',
files: [
{
name: 'customers',
media_folder: '{{media_folder}}/customers/',
fields: [
mainImageField,
logoField,
{ media_folder: '{{media_folder}}/nested', field: nestedField1 },
],
},
],
}),
fromJS({ path: 'src/customers/customers.md', slug: 'customers', data: { title: 'title' } }),
];
expect(selectMediaFolder(...args, mainImageField)).toBe('static/img/general/customers');
expect(selectMediaFolder(...args, logoField)).toBe('static/img/general/customers/logos');
expect(selectMediaFolder(...args, nestedField1)).toBe(
'static/img/general/customers/nested/nested1',
);
expect(selectMediaFolder(...args, nestedField2)).toBe(
'static/img/general/customers/nested/nested1/nested2',
);
});
});
@ -274,8 +337,8 @@ describe('entries', () => {
it('should resolve path from global media folder for collection with no media folder', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts' }),
fromJS({ media_folder: 'static/media' }),
fromJS({ name: 'posts', folder: 'posts' }),
undefined,
'image.png',
undefined,
@ -286,8 +349,8 @@ describe('entries', () => {
it('should resolve path from collection media folder for collection with media folder', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
fromJS({ media_folder: 'static/media' }),
fromJS({ name: 'posts', folder: 'posts', media_folder: '' }),
undefined,
'image.png',
undefined,
@ -298,9 +361,9 @@ describe('entries', () => {
it('should handle relative media_folder', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '../../static/media/' }),
Map({ path: 'posts/title/index.md' }),
fromJS({ media_folder: 'static/media' }),
fromJS({ name: 'posts', folder: 'posts', media_folder: '../../static/media/' }),
fromJS({ path: 'posts/title/index.md' }),
'image.png',
undefined,
),
@ -308,13 +371,14 @@ describe('entries', () => {
});
it('should handle field media_folder', () => {
const field = fromJS({ media_folder: '../../static/media/' });
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts' }),
Map({ path: 'posts/title/index.md' }),
fromJS({ media_folder: 'static/media' }),
fromJS({ name: 'posts', folder: 'posts', fields: [field] }),
fromJS({ path: 'posts/title/index.md' }),
'image.png',
'../../static/media/',
field,
),
).toBe('static/media/image.png');
});
@ -330,7 +394,7 @@ describe('entries', () => {
it('should resolve path from public folder for collection with no media folder', () => {
expect(
selectMediaFilePublicPath(
Map({ public_folder: '/media' }),
fromJS({ public_folder: '/media' }),
null,
'/media/image.png',
undefined,
@ -342,8 +406,8 @@ describe('entries', () => {
it('should resolve path from collection public folder for collection with public folder', () => {
expect(
selectMediaFilePublicPath(
Map({ public_folder: '/media' }),
Map({ name: 'posts', folder: 'posts', public_folder: '' }),
fromJS({ public_folder: '/media' }),
fromJS({ name: 'posts', folder: 'posts', public_folder: '' }),
'image.png',
undefined,
undefined,
@ -354,8 +418,8 @@ describe('entries', () => {
it('should handle relative public_folder', () => {
expect(
selectMediaFilePublicPath(
Map({ public_folder: '/media' }),
Map({ name: 'posts', folder: 'posts', public_folder: '../../static/media/' }),
fromJS({ public_folder: '/media' }),
fromJS({ name: 'posts', folder: 'posts', public_folder: '../../static/media/' }),
'image.png',
undefined,
undefined,
@ -403,10 +467,16 @@ describe('entries', () => {
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const field = fromJS({
name: 'title',
widget: 'string',
public_folder: '/{{public_folder}}/{{category}}/{{slug}}',
});
const collection = fromJS({
name: 'posts',
folder: 'content',
fields: [{ name: 'title', widget: 'string' }],
fields: [field],
});
expect(
@ -415,7 +485,7 @@ describe('entries', () => {
collection,
'image.png',
entry,
'/{{public_folder}}/{{category}}/{{slug}}',
field,
),
).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png');
});
@ -431,10 +501,16 @@ describe('entries', () => {
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const field = fromJS({
name: 'title',
widget: 'string',
public_folder: '/{{public_folder}}/{{category}}/{{slug}}',
});
const collection = fromJS({
name: 'posts',
folder: 'content',
fields: [{ name: 'title', widget: 'string' }],
fields: [field],
});
expect(
@ -443,7 +519,7 @@ describe('entries', () => {
collection,
'image.png',
entry,
'/{{public_folder}}/{{category}}/{{slug}}',
field,
),
).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png');
});

View File

@ -43,16 +43,68 @@ describe('mediaLibrary', () => {
);
});
it('should select draft media files when editing a draft', () => {
const { selectEditingDraft } = require('Reducers/entries');
it('should select draft media files from field when editing a draft', () => {
const { selectEditingDraft, selectMediaFolder } = require('Reducers/entries');
selectEditingDraft.mockReturnValue(true);
selectMediaFolder.mockReturnValue('/static/images/posts/logos');
const imageField = fromJS({ name: 'image' });
const collection = fromJS({ fields: [imageField] });
const entry = fromJS({
collection: 'posts',
mediaFiles: [
{ id: 1, path: '/static/images/posts/logos/logo.png' },
{ id: 2, path: '/static/images/posts/general/image.png' },
{ id: 3, path: '/static/images/posts/index.png' },
],
data: {},
});
const state = {
entryDraft: fromJS({ entry: { mediaFiles: [{ id: 1 }] } }),
config: {},
collections: fromJS({ posts: collection }),
entryDraft: fromJS({
entry,
}),
};
expect(selectMediaFiles(state)).toEqual([{ key: 1, id: 1 }]);
expect(selectMediaFiles(state, imageField)).toEqual([
{ id: 1, key: 1, path: '/static/images/posts/logos/logo.png' },
]);
expect(selectMediaFolder).toHaveBeenCalledWith(state.config, collection, entry, imageField);
});
it('should select draft media files from collection when editing a draft', () => {
const { selectEditingDraft, selectMediaFolder } = require('Reducers/entries');
selectEditingDraft.mockReturnValue(true);
selectMediaFolder.mockReturnValue('/static/images/posts');
const imageField = fromJS({ name: 'image' });
const collection = fromJS({ fields: [imageField] });
const entry = fromJS({
collection: 'posts',
mediaFiles: [
{ id: 1, path: '/static/images/posts/logos/logo.png' },
{ id: 2, path: '/static/images/posts/general/image.png' },
{ id: 3, path: '/static/images/posts/index.png' },
],
data: {},
});
const state = {
config: {},
collections: fromJS({ posts: collection }),
entryDraft: fromJS({
entry,
}),
};
expect(selectMediaFiles(state, imageField)).toEqual([
{ id: 3, key: 3, path: '/static/images/posts/index.png' },
]);
expect(selectMediaFolder).toHaveBeenCalledWith(state.config, collection, entry, imageField);
});
it('should select global media files when not editing a draft', () => {

View File

@ -115,56 +115,61 @@ const selectors = {
},
};
const getFieldsMediaFolders = (fields: EntryField[]) => {
const mediaFolders = fields.reduce((acc, f) => {
const getFieldsWithMediaFolders = (fields: EntryField[]) => {
const fieldsWithMediaFolders = fields.reduce((acc, f) => {
if (f.has('media_folder')) {
acc = [...acc, f.get('media_folder') as string];
acc = [...acc, f];
}
if (f.has('fields')) {
const fields = f.get('fields')?.toArray() as EntryField[];
acc = [...acc, ...getFieldsMediaFolders(fields)];
acc = [...acc, ...getFieldsWithMediaFolders(fields)];
}
if (f.has('field')) {
const field = f.get('field') as EntryField;
acc = [...acc, ...getFieldsMediaFolders([field])];
acc = [...acc, ...getFieldsWithMediaFolders([field])];
}
return acc;
}, [] as string[]);
}, [] as EntryField[]);
return mediaFolders;
return fieldsWithMediaFolders;
};
export const selectFieldsMediaFolders = (collection: Collection) => {
const getFileFromSlug = (collection: Collection, slug: string) => {
return collection
.get('files')
?.toArray()
.filter(f => f.get('name') === slug)[0];
};
export const selectFieldsWithMediaFolders = (collection: Collection, slug: string) => {
if (collection.has('folder')) {
const fields = collection.get('fields').toArray();
return getFieldsMediaFolders(fields);
return getFieldsWithMediaFolders(fields);
} else if (collection.has('files')) {
const fields = collection
.get('files')
?.toArray()
.map(f => f.get('fields').toArray()) as EntryField[][];
const flattened = [] as EntryField[];
return getFieldsMediaFolders(flattened.concat(...fields));
const fields =
getFileFromSlug(collection, slug)
?.get('fields')
.toArray() || [];
return getFieldsWithMediaFolders(fields);
}
return [];
};
export const selectMediaFolders = (state: State, collection: Collection, entry: EntryMap) => {
const fieldsFolders = selectFieldsMediaFolders(collection);
const folders = fieldsFolders.map(folder =>
selectMediaFolder(state.config, collection, entry, folder),
);
if (
collection.has('media_folder') ||
collection
.get('files')
?.find(file => file?.get('name') === entry?.get('slug') && file?.has('media_folder'))
) {
const fields = selectFieldsWithMediaFolders(collection, entry.get('slug'));
const folders = fields.map(f => selectMediaFolder(state.config, collection, entry, f));
if (collection.has('files')) {
const file = getFileFromSlug(collection, entry.get('slug'));
if (file) {
folders.unshift(selectMediaFolder(state.config, collection, entry, undefined));
}
}
if (collection.has('media_folder')) {
// stop evaluating media folders at collection level
collection = collection.delete('files');
folders.unshift(selectMediaFolder(state.config, collection, entry, undefined));
}

View File

@ -24,10 +24,12 @@ import {
EntriesRequestPayload,
EntryDraft,
EntryMap,
EntryField,
CollectionFiles,
} from '../types/redux';
import { folderFormatter } from '../lib/formatters';
import { isAbsolutePath, basename } from 'netlify-cms-lib-util';
import { trimStart } from 'lodash';
import { trim } from 'lodash';
let collection: string;
let loadedEntries: EntryObject[];
@ -139,62 +141,209 @@ export const selectEntries = (state: Entries, collection: string) => {
const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES';
const getCustomFolder = (
name: 'media_folder' | 'public_folder',
const getFileField = (collectionFiles: CollectionFiles, slug: string | undefined) => {
const file = collectionFiles.find(f => f?.get('name') === slug);
return file;
};
const hasCustomFolder = (
folderKey: 'media_folder' | 'public_folder',
collection: Collection | null,
slug: string | undefined,
fieldFolder: string | undefined,
field: EntryField | undefined,
) => {
if (!collection) {
return undefined;
return false;
}
if (fieldFolder !== undefined) {
return fieldFolder;
if (field && field.has(folderKey)) {
return true;
}
if (collection.has('files') && slug) {
const file = collection.get('files')?.find(f => f?.get('name') === slug);
if (file && file.has(name)) {
return file.get(name);
if (collection.has('files')) {
const file = getFileField(collection.get('files')!, slug);
if (file && file.has(folderKey)) {
return true;
}
}
if (collection.has(name)) {
return collection.get(name);
if (collection.has(folderKey)) {
return true;
}
return undefined;
return false;
};
const traverseFields = (
folderKey: 'media_folder' | 'public_folder',
config: Config,
collection: Collection,
entryMap: EntryMap | undefined,
field: EntryField,
fields: EntryField[],
currentFolder: string,
): string | null => {
const matchedField = fields.filter(f => f === field)[0];
if (matchedField) {
return folderFormatter(
matchedField.has(folderKey) ? matchedField.get(folderKey)! : `{{${folderKey}}}`,
entryMap,
collection,
currentFolder,
folderKey,
config.get('slug'),
);
}
for (let f of fields) {
if (!f.has(folderKey)) {
// add identity template if doesn't exist
f = f.set(folderKey, `{{${folderKey}}}`);
}
const folder = folderFormatter(
f.get(folderKey)!,
entryMap,
collection,
currentFolder,
folderKey,
config.get('slug'),
);
if (f.has('fields')) {
return traverseFields(
folderKey,
config,
collection,
entryMap,
field,
f.get('fields')!.toArray(),
folder,
);
} else if (f.has('field')) {
return traverseFields(
folderKey,
config,
collection,
entryMap,
field,
[f.get('field')!],
folder,
);
}
}
return null;
};
const evaluateFolder = (
folderKey: 'media_folder' | 'public_folder',
config: Config,
collection: Collection,
entryMap: EntryMap | undefined,
field: EntryField | undefined,
) => {
let currentFolder = config.get(folderKey);
// add identity template if doesn't exist
if (!collection.has(folderKey)) {
collection = collection.set(folderKey, `{{${folderKey}}}`);
}
if (collection.has('files')) {
// files collection evaluate the collection template
// then move on to the specific file configuration denoted by the slug
currentFolder = folderFormatter(
collection.get(folderKey)!,
entryMap,
collection,
currentFolder,
folderKey,
config.get('slug'),
);
let file = getFileField(collection.get('files')!, entryMap?.get('slug'));
if (file) {
if (!file.has(folderKey)) {
// add identity template if doesn't exist
file = file.set(folderKey, `{{${folderKey}}}`);
}
// evaluate the file template and keep evaluating until we match our field
currentFolder = folderFormatter(
file.get(folderKey)!,
entryMap,
collection,
currentFolder,
folderKey,
config.get('slug'),
);
if (field) {
const fieldFolder = traverseFields(
folderKey,
config,
collection,
entryMap,
field,
file.get('fields')!.toArray(),
currentFolder,
);
if (fieldFolder !== null) {
currentFolder = fieldFolder;
}
}
}
} else {
// folder collection, evaluate the collection template
// and keep evaluating until we match our field
currentFolder = folderFormatter(
collection.get(folderKey)!,
entryMap,
collection,
currentFolder,
folderKey,
config.get('slug'),
);
if (field) {
const fieldFolder = traverseFields(
folderKey,
config,
collection,
entryMap,
field,
collection.get('fields')!.toArray(),
currentFolder,
);
if (fieldFolder !== null) {
currentFolder = fieldFolder;
}
}
}
return currentFolder;
};
export const selectMediaFolder = (
config: Config,
collection: Collection | null,
entryMap: EntryMap | undefined,
fieldMediaFolder: string | undefined,
field: EntryField | undefined,
) => {
let mediaFolder = config.get('media_folder');
const name = 'media_folder';
let mediaFolder = config.get(name);
const customFolder = getCustomFolder(
'media_folder',
collection,
entryMap?.get('slug'),
fieldMediaFolder,
);
const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field);
if (customFolder !== undefined) {
if (customFolder) {
const entryPath = entryMap?.get('path');
if (entryPath) {
const entryDir = dirname(entryPath);
const folder = folderFormatter(
customFolder,
entryMap as EntryMap,
collection!,
mediaFolder,
'media_folder',
config.get('slug'),
);
// return absolute paths as is without the leading '/'
const folder = evaluateFolder(name, config, collection!, entryMap, field);
// return absolute paths as is
if (folder.startsWith('/')) {
mediaFolder = join(trimStart(folder, '/'));
mediaFolder = join(folder);
} else {
mediaFolder = join(entryDir, folder as string);
}
@ -203,7 +352,7 @@ export const selectMediaFolder = (
}
}
return mediaFolder;
return trim(mediaFolder, '/');
};
export const selectMediaFilePath = (
@ -211,13 +360,13 @@ export const selectMediaFilePath = (
collection: Collection | null,
entryMap: EntryMap | undefined,
mediaPath: string,
fieldMediaFolder: string | undefined,
field: EntryField | undefined,
) => {
if (isAbsolutePath(mediaPath)) {
return mediaPath;
}
const mediaFolder = selectMediaFolder(config, collection, entryMap, fieldMediaFolder);
const mediaFolder = selectMediaFolder(config, collection, entryMap, field);
return join(mediaFolder, basename(mediaPath));
};
@ -227,30 +376,19 @@ export const selectMediaFilePublicPath = (
collection: Collection | null,
mediaPath: string,
entryMap: EntryMap | undefined,
fieldPublicFolder: string | undefined,
field: EntryField | undefined,
) => {
if (isAbsolutePath(mediaPath)) {
return mediaPath;
}
let publicFolder = config.get('public_folder');
const name = 'public_folder';
let publicFolder = config.get(name);
const customFolder = getCustomFolder(
'public_folder',
collection,
entryMap?.get('slug'),
fieldPublicFolder,
);
const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field);
if (customFolder !== undefined) {
publicFolder = folderFormatter(
customFolder,
entryMap,
collection!,
publicFolder,
'public_folder',
config.get('slug'),
);
if (customFolder) {
publicFolder = evaluateFolder(name, config, collection!, entryMap, field);
}
return join(publicFolder, basename(mediaPath));

View File

@ -19,7 +19,7 @@ import {
MEDIA_DISPLAY_URL_SUCCESS,
MEDIA_DISPLAY_URL_FAILURE,
} from '../actions/mediaLibrary';
import { selectEditingDraft } from './entries';
import { selectEditingDraft, selectMediaFolder } from './entries';
import { selectIntegration } from './';
import {
State,
@ -28,7 +28,9 @@ import {
MediaFile,
MediaFileMap,
DisplayURLState,
EntryField,
} from '../types/redux';
import { dirname } from 'path';
const defaultState: {
isVisible: boolean;
@ -40,6 +42,7 @@ const defaultState: {
page?: number;
files?: MediaFile[];
config: Map<string, string>;
field?: EntryField;
} = {
isVisible: false,
showMediaButton: true,
@ -56,14 +59,7 @@ const mediaLibrary = (state = Map(defaultState), action: MediaLibraryAction) =>
map.set('showMediaButton', action.payload.enableStandalone());
});
case MEDIA_LIBRARY_OPEN: {
const {
controlID,
forImage,
privateUpload,
config,
mediaFolder,
publicFolder,
} = action.payload;
const { controlID, forImage, privateUpload, config, field } = action.payload;
const libConfig = config || Map();
const privateUploadChanged = state.get('privateUpload') !== privateUpload;
if (privateUploadChanged) {
@ -75,6 +71,7 @@ const mediaLibrary = (state = Map(defaultState), action: MediaLibraryAction) =>
privateUpload,
config: libConfig,
controlMedia: Map(),
field,
});
}
return state.withMutations(map => {
@ -84,8 +81,7 @@ const mediaLibrary = (state = Map(defaultState), action: MediaLibraryAction) =>
map.set('canInsert', !!controlID);
map.set('privateUpload', privateUpload);
map.set('config', libConfig);
map.set('mediaFolder', mediaFolder);
map.set('publicFolder', publicFolder);
map.set('field', field);
});
}
case MEDIA_LIBRARY_CLOSE:
@ -218,7 +214,7 @@ const mediaLibrary = (state = Map(defaultState), action: MediaLibraryAction) =>
}
};
export function selectMediaFiles(state: State) {
export function selectMediaFiles(state: State, field?: EntryField) {
const { mediaLibrary, entryDraft } = state;
const editingDraft = selectEditingDraft(state.entryDraft);
const integration = selectIntegration(state, null, 'assetStore');
@ -228,7 +224,12 @@ export function selectMediaFiles(state: State) {
const entryFiles = entryDraft
.getIn(['entry', 'mediaFiles'], List<MediaFileMap>())
.toJS() as MediaFile[];
files = entryFiles.map(file => ({ key: file.id, ...file }));
const entry = entryDraft.get('entry');
const collection = state.collections.get(entry?.get('collection'));
const mediaFolder = selectMediaFolder(state.config, collection, entry, field);
files = entryFiles
.filter(f => dirname(f.path) === mediaFolder)
.map(file => ({ key: file.id, ...file }));
} else {
files = mediaLibrary.get('files') || [];
}

View File

@ -2,7 +2,7 @@ import { Action } from 'redux';
import { StaticallyTypedRecord } from './immutable';
import { Map, List } from 'immutable';
import AssetProxy from '../valueObjects/AssetProxy';
import { ImplementationMediaFile } from 'netlify-cms-lib-util';
import { MediaFile as BackendMediaFile } from '../backend';
export type SlugConfig = StaticallyTypedRecord<{
encoding: string;
@ -164,7 +164,7 @@ export interface MediaLibraryInstance {
export type DisplayURL = { id: string; path: string } | string;
export type MediaFile = ImplementationMediaFile & { key?: string };
export type MediaFile = BackendMediaFile & { key?: string };
export type MediaFileMap = StaticallyTypedRecord<MediaFile>;
@ -311,8 +311,7 @@ export interface MediaLibraryAction extends Action<string> {
forImage: boolean;
privateUpload: boolean;
config: Map<string, string>;
mediaFolder?: string;
publicFolder?: string;
field?: EntryField;
} & { mediaPath: string | string[] } & { page: number } & {
files: MediaFile[];
page: number;

View File

@ -1,21 +1,23 @@
import { EntryField } from '../types/redux';
interface AssetProxyArgs {
path: string;
url?: string;
file?: File;
folder?: string;
field?: EntryField;
}
export default class AssetProxy {
url: string;
fileObj?: File;
path: string;
folder?: string;
field?: EntryField;
constructor({ url, file, path, folder }: AssetProxyArgs) {
constructor({ url, file, path, field }: AssetProxyArgs) {
this.url = url ? url : window.URL.createObjectURL(file);
this.fileObj = file;
this.path = path;
this.folder = folder;
this.field = field;
}
toString(): string {
@ -38,6 +40,6 @@ export default class AssetProxy {
}
}
export function createAssetProxy({ url, file, path, folder }: AssetProxyArgs): AssetProxy {
return new AssetProxy({ url, file, path, folder });
export function createAssetProxy({ url, file, path, field }: AssetProxyArgs): AssetProxy {
return new AssetProxy({ url, file, path, field });
}

View File

@ -1,5 +1,5 @@
import { isBoolean } from 'lodash';
import { ImplementationMediaFile } from 'netlify-cms-lib-util';
import { MediaFile } from '../backend';
interface Options {
partial?: boolean;
@ -9,7 +9,7 @@ interface Options {
label?: string | null;
metaData?: unknown | null;
isModification?: boolean | null;
mediaFiles?: ImplementationMediaFile[] | null;
mediaFiles?: MediaFile[] | null;
}
export interface EntryValue {
@ -23,7 +23,7 @@ export interface EntryValue {
label: string | null;
metaData: unknown | null;
isModification: boolean | null;
mediaFiles: ImplementationMediaFile[];
mediaFiles: MediaFile[];
}
export function createEntry(collection: string, slug = '', path = '', options: Options = {}) {

View File

@ -14,8 +14,7 @@ const image = {
// eslint-disable-next-line react/display-name
toPreview: ({ alt, image, title }, getAsset, fields) => {
const imageField = fields?.find(f => f.get('widget') === 'image');
const folder = imageField?.get('media_folder');
const src = getAsset(image, folder);
const src = getAsset(image, imageField);
return <img src={src || ''} alt={alt || ''} title={title || ''} />;
},
pattern: /^!\[(.*)\]\((.*?)(\s"(.*)")?\)$/,

View File

@ -18,7 +18,6 @@ export interface ImplementationMediaFile {
draft?: boolean;
url?: string;
file?: File;
folder?: string;
}
export interface UnpublishedEntryMediaFile {

View File

@ -12,11 +12,11 @@ const FileLink = styled(({ href, path }) => (
display: block;
`;
function FileLinkList({ values, getAsset, folder }) {
function FileLinkList({ values, getAsset, field }) {
return (
<div>
{values.map(value => (
<FileLink key={value} path={value} href={getAsset(value, folder)} />
<FileLink key={value} path={value} href={getAsset(value, field)} />
))}
</div>
);
@ -24,12 +24,10 @@ function FileLinkList({ values, getAsset, folder }) {
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} folder={folder} />;
return <FileLinkList values={value} getAsset={getAsset} field={field} />;
}
return <FileLink key={value} path={value} href={getAsset(value, folder)} />;
return <FileLink key={value} path={value} href={getAsset(value, field)} />;
}
const FilePreview = props => (

View File

@ -165,8 +165,7 @@ export default function withFileControl({ forImage } = {}) {
value,
allowMultiple: !!mediaLibraryFieldOptions.get('allow_multiple', true),
config: mediaLibraryFieldOptions.get('config'),
mediaFolder: field.get('media_folder'),
publicFolder: field.get('public_folder'),
field,
});
};
@ -211,21 +210,20 @@ export default function withFileControl({ forImage } = {}) {
renderImages = () => {
const { getAsset, value, field } = this.props;
const folder = field.get('media_folder');
if (isMultiple(value)) {
return (
<MultiImageWrapper>
{value.map(val => (
<ImageWrapper key={val}>
<Image src={getAsset(val, folder) || ''} />
<Image src={getAsset(val, field) || ''} />
</ImageWrapper>
))}
</MultiImageWrapper>
);
}
const src = getAsset(value, folder);
const src = getAsset(value, field);
return (
<ImageWrapper>
<Image src={src || ''} />

View File

@ -11,7 +11,7 @@ const StyledImage = styled(({ src }) => <img src={src || ''} role="presentation"
`;
const StyledImageAsset = ({ getAsset, value, field }) => {
return <StyledImage src={getAsset(value, field.get('media_folder'))} />;
return <StyledImage src={getAsset(value, field)} />;
};
const ImagePreviewContent = props => {