feat: bundle assets with content (#2958)

* fix(media_folder_relative): use collection name in unpublished entry

* refactor: pass arguments as object to AssetProxy ctor

* feat: support media folders per collection

* feat: resolve media files path based on entry path

* fix: asset public path resolving

* refactor: introduce typescript for AssetProxy

* refactor: code cleanup

* refactor(asset-proxy): add tests,switch to typescript,extract arguments

* refactor: typescript for editorialWorkflow

* refactor: add typescript for media library actions

* refactor: fix type error on map set

* refactor: move locale selector into reducer

* refactor: add typescript for entries actions

* refactor: remove duplication between asset store and media lib

* feat: load assets from backend using API

* refactor(github): add typescript, cache media files

* fix: don't load media URL if already loaded

* feat: add media folder config to collection

* fix: load assets from API when not in UI state

* feat: load entry media files when opening media library

* fix: editorial workflow draft media files bug fixes

* test(unit): fix unit tests

* fix: editor control losing focus

* style: add eslint object-shorthand rule

* test(cypress): re-record mock data

* fix: fix non github backends, large media

* test: uncomment only in tests

* fix(backend-test): add missing displayURL property

* test(e2e): add media library tests

* test(e2e): enable visual testing

* test(e2e): add github backend media library tests

* test(e2e): add git-gateway large media tests

* chore: post rebase fixes

* test: fix tests

* test: fix tests

* test(cypress): fix tests

* docs: add media_folder docs

* test(e2e): add media library delete test

* test(e2e): try and fix image comparison on CI

* ci: reduce test machines from 9 to 8

* test: add reducers and selectors unit tests

* test(e2e): disable visual regression testing for now

* test: add getAsset unit tests

* refactor: use Asset class component instead of hooks

* build: don't inline source maps

* test: add more media path tests
This commit is contained in:
Erez Rokah
2019-12-18 18:16:02 +02:00
committed by Shawn Erquhart
parent 7e4d4c1cc4
commit 2b41d8a838
231 changed files with 37961 additions and 18373 deletions

View File

@ -1,6 +1,6 @@
import { Map } from 'immutable';
import { configLoaded, configLoading, configFailed } from 'Actions/config';
import config from 'Reducers/config';
import config, { selectLocale } from 'Reducers/config';
describe('config', () => {
it('should handle an empty state', () => {
@ -22,4 +22,8 @@ describe('config', () => {
Map({ error: 'Error: Config could not be loaded' }),
);
});
it('should default to "en" locale', () => {
expect(selectLocale(Map())).toEqual('en');
});
});

View File

@ -1,67 +1,215 @@
import { Map, OrderedMap, fromJS } from 'immutable';
import * as actions from 'Actions/entries';
import reducer from '../entries';
import reducer, {
selectMediaFolder,
selectMediaFilePath,
selectMediaFilePublicPath,
} from '../entries';
import { EDITORIAL_WORKFLOW } from '../../constants/publishModes';
const initialState = OrderedMap({
posts: Map({ name: 'posts' }),
});
describe('entries', () => {
it('should mark entries as fetching', () => {
expect(reducer(initialState, actions.entriesLoading(Map({ name: 'posts' })))).toEqual(
OrderedMap(
fromJS({
posts: { name: 'posts' },
pages: {
posts: { isFetching: true },
},
}),
),
);
describe('reducer', () => {
it('should mark entries as fetching', () => {
expect(reducer(initialState, actions.entriesLoading(Map({ name: 'posts' })))).toEqual(
OrderedMap(
fromJS({
posts: { name: 'posts' },
pages: {
posts: { isFetching: true },
},
}),
),
);
});
it('should handle loaded entries', () => {
const entries = [
{ slug: 'a', path: '' },
{ slug: 'b', title: 'B' },
];
expect(
reducer(initialState, actions.entriesLoaded(Map({ name: 'posts' }), entries, 0)),
).toEqual(
OrderedMap(
fromJS({
posts: { name: 'posts' },
entities: {
'posts.a': { slug: 'a', path: '', isFetching: false },
'posts.b': { slug: 'b', title: 'B', isFetching: false },
},
pages: {
posts: {
page: 0,
ids: ['a', 'b'],
},
},
}),
),
);
});
it('should handle loaded entry', () => {
const entry = { slug: 'a', path: '' };
expect(reducer(initialState, actions.entryLoaded(Map({ name: 'posts' }), entry))).toEqual(
OrderedMap(
fromJS({
posts: { name: 'posts' },
entities: {
'posts.a': { slug: 'a', path: '' },
},
pages: {
posts: {
ids: ['a'],
},
},
}),
),
);
});
});
it('should handle loaded entries', () => {
const entries = [
{ slug: 'a', path: '' },
{ slug: 'b', title: 'B' },
];
expect(
reducer(initialState, actions.entriesLoaded(Map({ name: 'posts' }), entries, 0)),
).toEqual(
OrderedMap(
fromJS({
posts: { name: 'posts' },
entities: {
'posts.a': { slug: 'a', path: '', isFetching: false },
'posts.b': { slug: 'b', title: 'B', isFetching: false },
},
pages: {
posts: {
page: 0,
ids: ['a', 'b'],
},
},
}),
),
);
describe('selectMediaFolder', () => {
it('should return global media folder when not in editorial workflow', () => {
expect(selectMediaFolder(Map({ media_folder: 'static/media' }))).toEqual('static/media');
});
it("should return global media folder when in editorial workflow and collection doesn't specify media_folder", () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts' }),
),
).toEqual('static/media');
});
it('should return draft media folder when in editorial workflow, collection specifies media_folder and entry path is null', () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
null,
),
).toEqual('posts/DRAFT_MEDIA_FILES');
});
it('should return relative media folder when in editorial workflow, collection specifies media_folder and entry path is not null', () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
'posts/title/index.md',
),
).toEqual('posts/title');
});
it('should resolve relative media folder', () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts', folder: 'posts', media_folder: '../' }),
'posts/title/index.md',
),
).toEqual('posts/');
});
});
it('should handle loaded entry', () => {
const entry = { slug: 'a', path: '' };
expect(reducer(initialState, actions.entryLoaded(Map({ name: 'posts' }), entry))).toEqual(
OrderedMap(
fromJS({
posts: { name: 'posts' },
entities: {
'posts.a': { slug: 'a', path: '' },
},
pages: {
posts: {
ids: ['a'],
},
},
}),
),
);
describe('selectMediaFilePath', () => {
it('should return absolute URL as is', () => {
expect(selectMediaFilePath(null, null, null, 'https://www.netlify.com/image.png')).toBe(
'https://www.netlify.com/image.png',
);
});
it('should resolve path from global media folder when absolute path', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
null,
null,
'/media/image.png',
),
).toBe('static/media/image.png');
});
it('should resolve path from global media folder when relative path for collection with no media folder', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts', folder: 'posts' }),
null,
'image.png',
),
).toBe('static/media/image.png');
});
it('should resolve path from collection media folder when relative path for collection with media folder', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
null,
'image.png',
),
).toBe('posts/DRAFT_MEDIA_FILES/image.png');
});
it('should handle relative media_folder', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts', folder: 'posts', media_folder: '../../static/media/' }),
'posts/title/index.md',
'image.png',
),
).toBe('static/media/image.png');
});
});
describe('selectMediaFilePublicPath', () => {
it('should return absolute URL as is', () => {
expect(selectMediaFilePublicPath(null, null, 'https://www.netlify.com/image.png')).toBe(
'https://www.netlify.com/image.png',
);
});
it('should resolve path from public folder when not in editorial workflow', () => {
expect(
selectMediaFilePublicPath(Map({ public_folder: '/media' }), null, '/media/image.png'),
).toBe('/media/image.png');
});
it('should resolve path from public folder when in editorial workflow for collection with no media folder', () => {
expect(
selectMediaFilePublicPath(
Map({ public_folder: '/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts', folder: 'posts' }),
'image.png',
),
).toBe('/media/image.png');
});
it('should resolve path from collection media folder when in editorial workflow for collection with media folder', () => {
expect(
selectMediaFilePublicPath(
Map({ public_folder: '/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
'image.png',
),
).toBe('image.png');
});
it('should handle relative media_folder', () => {
expect(
selectMediaFilePublicPath(
Map({ public_folder: '/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts', folder: 'posts', media_folder: '../../static/media/' }),
'image.png',
),
).toBe('../../static/media/image.png');
});
});
});

View File

@ -1,4 +1,4 @@
import { Map, List, fromJS } from 'immutable';
import { Map, fromJS } from 'immutable';
import * as actions from 'Actions/entries';
import reducer from '../entryDraft';
@ -6,7 +6,6 @@ jest.mock('uuid/v4', () => jest.fn(() => '1'));
const initialState = Map({
entry: Map(),
mediaFiles: List(),
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
@ -33,7 +32,6 @@ describe('entryDraft reducer', () => {
...entry,
newRecord: false,
},
mediaFiles: [],
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
@ -52,7 +50,6 @@ describe('entryDraft reducer', () => {
...entry,
newRecord: true,
},
mediaFiles: [],
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
@ -124,16 +121,15 @@ describe('entryDraft reducer', () => {
describe('REMOVE_DRAFT_ENTRY_MEDIA_FILE', () => {
it('should remove a media file', () => {
const actualState = reducer(
initialState.set('mediaFiles', List([{ id: '1' }, { id: '2' }])),
initialState.setIn(['entry', 'mediaFiles'], fromJS([{ id: '1' }, { id: '2' }])),
actions.removeDraftEntryMediaFile({ id: '1' }),
);
expect(actualState.toJS()).toEqual({
entry: {},
mediaFiles: [{ id: '2' }],
entry: { mediaFiles: [{ id: '2' }] },
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
hasChanged: true,
key: '',
});
});
@ -142,34 +138,15 @@ describe('entryDraft reducer', () => {
describe('ADD_DRAFT_ENTRY_MEDIA_FILE', () => {
it('should overwrite an existing media file', () => {
const actualState = reducer(
initialState.set('mediaFiles', List([{ id: '1', name: 'old' }])),
initialState.setIn(['entry', 'mediaFiles'], fromJS([{ id: '1', name: 'old' }])),
actions.addDraftEntryMediaFile({ id: '1', name: 'new' }),
);
expect(actualState.toJS()).toEqual({
entry: {},
mediaFiles: [{ id: '1', name: 'new' }],
entry: { mediaFiles: [{ id: '1', name: 'new' }] },
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
key: '',
});
});
});
describe('SET_DRAFT_ENTRY_MEDIA_FILES', () => {
it('should overwrite an existing media file', () => {
const actualState = reducer(
initialState,
actions.setDraftEntryMediaFiles([{ id: '1' }, { id: '2' }]),
);
expect(actualState.toJS()).toEqual({
entry: {},
mediaFiles: [{ id: '1' }, { id: '2' }],
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
hasChanged: true,
key: '',
});
});
@ -177,7 +154,7 @@ describe('entryDraft reducer', () => {
describe('DRAFT_CREATE_FROM_LOCAL_BACKUP', () => {
it('should create draft from local backup', () => {
const localBackup = Map({ entry: fromJS(entry), mediaFiles: List([{ id: '1' }]) });
const localBackup = Map({ entry: fromJS({ ...entry, mediaFiles: [{ id: '1' }] }) });
const actualState = reducer(initialState.set('localBackup', localBackup), {
type: actions.DRAFT_CREATE_FROM_LOCAL_BACKUP,
@ -185,9 +162,9 @@ describe('entryDraft reducer', () => {
expect(actualState.toJS()).toEqual({
entry: {
...entry,
mediaFiles: [{ id: '1' }],
newRecord: false,
},
mediaFiles: [{ id: '1' }],
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: true,
@ -200,17 +177,18 @@ describe('entryDraft reducer', () => {
it('should set local backup', () => {
const mediaFiles = [{ id: '1' }];
const actualState = reducer(initialState, actions.localBackupRetrieved(entry, mediaFiles));
const actualState = reducer(
initialState,
actions.localBackupRetrieved({ ...entry, mediaFiles }),
);
expect(actualState.toJS()).toEqual({
entry: {},
mediaFiles: [],
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
localBackup: {
entry,
mediaFiles: [{ id: '1' }],
entry: { ...entry, mediaFiles: [{ id: '1' }] },
},
key: '',
});

View File

@ -0,0 +1,70 @@
import { fromJS } from 'immutable';
import integrations from '../integrations';
import { CONFIG_SUCCESS } from '../../actions/config';
describe('integrations', () => {
it('should return default state when no integrations', () => {
const result = integrations(null, {
type: CONFIG_SUCCESS,
payload: fromJS({ integrations: [] }),
});
expect(result && result.toJS()).toEqual({
providers: {},
hooks: {},
});
});
it('should return hooks and providers map when has integrations', () => {
const result = integrations(null, {
type: CONFIG_SUCCESS,
payload: fromJS({
integrations: [
{
hooks: ['listEntries'],
collections: '*',
provider: 'algolia',
applicationID: 'applicationID',
apiKey: 'apiKey',
},
{
hooks: ['listEntries'],
collections: ['posts'],
provider: 'algolia',
applicationID: 'applicationID',
apiKey: 'apiKey',
},
{
hooks: ['assetStore'],
provider: 'assetStore',
getSignedFormURL: 'https://asset.store.com/signedUrl',
},
],
collections: [{ name: 'posts' }, { name: 'pages' }, { name: 'faq' }],
}),
});
expect(result && result.toJS()).toEqual({
providers: {
algolia: {
applicationID: 'applicationID',
apiKey: 'apiKey',
},
assetStore: {
getSignedFormURL: 'https://asset.store.com/signedUrl',
},
},
hooks: {
posts: {
listEntries: 'algolia',
},
pages: {
listEntries: 'algolia',
},
faq: {
listEntries: 'algolia',
},
assetStore: 'assetStore',
},
});
});
});

View File

@ -1,38 +1,16 @@
import { Map } from 'immutable';
import { ADD_MEDIA_FILES_TO_LIBRARY, mediaDeleted } from 'Actions/mediaLibrary';
import mediaLibrary from '../mediaLibrary';
import { Map, fromJS } from 'immutable';
import { mediaDeleted } from 'Actions/mediaLibrary';
import mediaLibrary, {
selectMediaFiles,
selectMediaFileByPath,
selectMediaDisplayURL,
} from '../mediaLibrary';
jest.mock('uuid/v4');
jest.mock('Reducers/editorialWorkflow');
jest.mock('Reducers');
describe('mediaLibrary', () => {
const uuid = require('uuid/v4');
it('should add media files to library', () => {
uuid.mockReturnValue('newKey');
expect(
mediaLibrary(
Map({
files: [
{ sha: 'old', path: 'path', key: 'key1' },
{ sha: 'sha', path: 'some-other-pas', key: 'key2' },
],
}),
{
type: ADD_MEDIA_FILES_TO_LIBRARY,
payload: { mediaFiles: [{ sha: 'new', path: 'path' }] },
},
),
).toEqual(
Map({
files: [
{ sha: 'new', path: 'path', key: 'newKey' },
{ sha: 'sha', path: 'some-other-pas', key: 'key2' },
],
}),
);
});
it('should remove media file by key', () => {
expect(
mediaLibrary(
@ -64,4 +42,60 @@ describe('mediaLibrary', () => {
}),
);
});
it('should select draft media files when editing a workflow draft', () => {
const { selectEditingWorkflowDraft } = require('Reducers/editorialWorkflow');
selectEditingWorkflowDraft.mockReturnValue(true);
const state = {
entryDraft: fromJS({ entry: { mediaFiles: [{ id: 1 }] } }),
};
expect(selectMediaFiles(state)).toEqual([{ key: 1, id: 1 }]);
});
it('should select global media files when not editing a workflow draft', () => {
const { selectEditingWorkflowDraft } = require('Reducers/editorialWorkflow');
selectEditingWorkflowDraft.mockReturnValue(false);
const state = {
mediaLibrary: Map({ files: [{ id: 1 }] }),
};
expect(selectMediaFiles(state)).toEqual([{ id: 1 }]);
});
it('should select global media files when not using asset store integration', () => {
const { selectIntegration } = require('Reducers');
selectIntegration.mockReturnValue({});
const state = {
mediaLibrary: Map({ files: [{ id: 1 }] }),
};
expect(selectMediaFiles(state)).toEqual([{ id: 1 }]);
});
it('should return media file by path', () => {
const { selectEditingWorkflowDraft } = require('Reducers/editorialWorkflow');
selectEditingWorkflowDraft.mockReturnValue(false);
const state = {
mediaLibrary: Map({ files: [{ id: 1, path: 'path' }] }),
};
expect(selectMediaFileByPath(state, 'path')).toEqual({ id: 1, path: 'path' });
});
it('should return media display URL state', () => {
const state = {
mediaLibrary: fromJS({ displayURLs: { id: { url: 'url' } } }),
};
expect(selectMediaDisplayURL(state, 'id')).toEqual(Map({ url: 'url' }));
});
});

View File

@ -1,25 +0,0 @@
import { Map } from 'immutable';
import { addAssets, addAsset, removeAsset } from 'Actions/media';
import reducer from '../medias';
jest.mock('ValueObjects/AssetProxy');
describe('medias', () => {
it('should add assets', () => {
expect(reducer(Map(), addAssets([{ public_path: 'public_path' }]))).toEqual(
Map({ public_path: { public_path: 'public_path' } }),
);
});
it('should add asset', () => {
expect(reducer(Map(), addAsset({ public_path: 'public_path' }))).toEqual(
Map({ public_path: { public_path: 'public_path' } }),
);
});
it('should remove asset', () => {
expect(
reducer(Map({ public_path: { public_path: 'public_path' } }), removeAsset('public_path')),
).toEqual(Map());
});
});

View File

@ -0,0 +1,20 @@
import { Map, fromJS } from 'immutable';
import { addAssets, addAsset, removeAsset } from '../../actions/media';
import reducer from '../medias';
import { createAssetProxy } from '../../valueObjects/AssetProxy';
describe('medias', () => {
const asset = createAssetProxy({ url: 'url', path: 'path' });
it('should add assets', () => {
expect(reducer(fromJS({}), addAssets([asset]))).toEqual(Map({ path: asset }));
});
it('should add asset', () => {
expect(reducer(fromJS({}), addAsset(asset))).toEqual(Map({ path: asset }));
});
it('should remove asset', () => {
expect(reducer(fromJS({ path: asset }), removeAsset(asset.path))).toEqual(Map());
});
});

View File

@ -1,172 +0,0 @@
import { List } from 'immutable';
import { get, escapeRegExp } from 'lodash';
import consoleError from 'Lib/consoleError';
import { CONFIG_SUCCESS } from 'Actions/config';
import { FILES, FOLDER } from 'Constants/collectionTypes';
import { INFERABLE_FIELDS, IDENTIFIER_FIELDS } from 'Constants/fieldInference';
import { formatExtensions } from 'Formats/formats';
const collections = (state = null, action) => {
switch (action.type) {
case CONFIG_SUCCESS: {
const configCollections = action.payload ? action.payload.get('collections') : List();
return configCollections
.toOrderedMap()
.map(collection => {
if (collection.has('folder')) {
return collection.set('type', FOLDER);
}
if (collection.has('files')) {
return collection.set('type', FILES);
}
})
.mapKeys((key, collection) => collection.get('name'));
}
default:
return state;
}
};
const selectors = {
[FOLDER]: {
entryExtension(collection) {
return (
collection.get('extension') ||
get(formatExtensions, collection.get('format') || 'frontmatter')
).replace(/^\./, '');
},
fields(collection) {
return collection.get('fields');
},
entryPath(collection, slug) {
const folder = collection.get('folder').replace(/\/$/, '');
return `${folder}/${slug}.${this.entryExtension(collection)}`;
},
entrySlug(collection, path) {
const folder = collection.get('folder').replace(/\/$/, '');
const slug = path
.split(folder + '/')
.pop()
.replace(new RegExp(`\\.${escapeRegExp(this.entryExtension(collection))}$`), '');
return slug;
},
listMethod() {
return 'entriesByFolder';
},
allowNewEntries(collection) {
return collection.get('create');
},
allowDeletion(collection) {
return collection.get('delete', true);
},
templateName(collection) {
return collection.get('name');
},
},
[FILES]: {
fileForEntry(collection, slug) {
const files = collection.get('files');
return files && files.filter(f => f.get('name') === slug).get(0);
},
fields(collection, slug) {
const file = this.fileForEntry(collection, slug);
return file && file.get('fields');
},
entryPath(collection, slug) {
const file = this.fileForEntry(collection, slug);
return file && file.get('file');
},
entrySlug(collection, path) {
const file = collection
.get('files')
.filter(f => f.get('file') === path)
.get(0);
return file && file.get('name');
},
entryLabel(collection, slug) {
const path = this.entryPath(collection, slug);
const files = collection.get('files');
return files && files.find(f => f.get('file') === path).get('label');
},
listMethod() {
return 'entriesByFiles';
},
allowNewEntries() {
return false;
},
allowDeletion(collection) {
return collection.get('delete', false);
},
templateName(collection, slug) {
return slug;
},
},
};
export const selectFields = (collection, slug) =>
selectors[collection.get('type')].fields(collection, slug);
export const selectFolderEntryExtension = collection =>
selectors[FOLDER].entryExtension(collection);
export const selectFileEntryLabel = (collection, slug) =>
selectors[FILES].entryLabel(collection, slug);
export const selectEntryPath = (collection, slug) =>
selectors[collection.get('type')].entryPath(collection, slug);
export const selectEntrySlug = (collection, path) =>
selectors[collection.get('type')].entrySlug(collection, path);
export const selectListMethod = collection => selectors[collection.get('type')].listMethod();
export const selectAllowNewEntries = collection =>
selectors[collection.get('type')].allowNewEntries(collection);
export const selectAllowDeletion = collection =>
selectors[collection.get('type')].allowDeletion(collection);
export const selectTemplateName = (collection, slug) =>
selectors[collection.get('type')].templateName(collection, slug);
export const selectIdentifier = collection => {
const identifier = collection.get('identifier_field');
const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : IDENTIFIER_FIELDS;
const fieldNames = collection.get('fields', []).map(field => field.get('name'));
return identifierFields.find(id =>
fieldNames.find(name => name.toLowerCase().trim() === id.toLowerCase().trim()),
);
};
export const selectInferedField = (collection, fieldName) => {
if (fieldName === 'title' && collection.get('identifier_field')) {
return selectIdentifier(collection);
}
const inferableField = INFERABLE_FIELDS[fieldName];
const fields = collection.get('fields');
let field;
// If collection has no fields or fieldName is not defined within inferables list, return null
if (!fields || !inferableField) return null;
// Try to return a field of the specified type with one of the synonyms
const mainTypeFields = fields
.filter(f => f.get('widget', 'string') === inferableField.type)
.map(f => f.get('name'));
field = mainTypeFields.filter(f => inferableField.synonyms.indexOf(f) !== -1);
if (field && field.size > 0) return field.first();
// Try to return a field for each of the specified secondary types
const secondaryTypeFields = fields
.filter(f => inferableField.secondaryTypes.indexOf(f.get('widget', 'string')) !== -1)
.map(f => f.get('name'));
field = secondaryTypeFields.filter(f => inferableField.synonyms.indexOf(f) !== -1);
if (field && field.size > 0) return field.first();
// Try to return the first field of the specified type
if (inferableField.fallbackToFirstField && mainTypeFields.size > 0) return mainTypeFields.first();
// Coundn't infer the field. Show error and return null.
if (inferableField.showError) {
consoleError(
`The Field ${fieldName} is missing for the collection “${collection.get('name')}`,
`Netlify CMS tries to infer the entry ${fieldName} automatically, but one couldn't be found for entries of the collection “${collection.get(
'name',
)}”. Please check your site configuration.`,
);
}
return null;
};
export default collections;

View File

@ -0,0 +1,195 @@
import { List } from 'immutable';
import { get, escapeRegExp } from 'lodash';
import consoleError from '../lib/consoleError';
import { CONFIG_SUCCESS } from '../actions/config';
import { FILES, FOLDER } from '../constants/collectionTypes';
import { INFERABLE_FIELDS, IDENTIFIER_FIELDS } from '../constants/fieldInference';
import { formatExtensions } from '../formats/formats';
import { CollectionsAction, Collection, CollectionFiles, EntryField } from '../types/redux';
const collections = (state = null, action: CollectionsAction) => {
switch (action.type) {
case CONFIG_SUCCESS: {
const configCollections = action.payload
? action.payload.get('collections')
: List<Collection>();
return (
configCollections
.toOrderedMap()
.map(item => {
const collection = item as Collection;
if (collection.has('folder')) {
return collection.set('type', FOLDER);
}
if (collection.has('files')) {
return collection.set('type', FILES);
}
})
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
.mapKeys((key: string, collection: Collection) => collection.get('name'))
);
}
default:
return state;
}
};
enum ListMethod {
ENTRIES_BY_FOLDER = 'entriesByFolder',
ENTRIES_BY_FILES = 'entriesByFiles',
}
const selectors = {
[FOLDER]: {
entryExtension(collection: Collection) {
return (
collection.get('extension') ||
get(formatExtensions, collection.get('format') || 'frontmatter')
).replace(/^\./, '');
},
fields(collection: Collection) {
return collection.get('fields');
},
entryPath(collection: Collection, slug: string) {
const folder = (collection.get('folder') as string).replace(/\/$/, '');
return `${folder}/${slug}.${this.entryExtension(collection)}`;
},
entrySlug(collection: Collection, path: string) {
const folder = (collection.get('folder') as string).replace(/\/$/, '');
const slug = path
.split(folder + '/')
.pop()
?.replace(new RegExp(`\\.${escapeRegExp(this.entryExtension(collection))}$`), '');
return slug;
},
listMethod() {
return ListMethod.ENTRIES_BY_FOLDER;
},
allowNewEntries(collection: Collection) {
return collection.get('create');
},
allowDeletion(collection: Collection) {
return collection.get('delete', true);
},
templateName(collection: Collection) {
return collection.get('name');
},
},
[FILES]: {
fileForEntry(collection: Collection, slug: string) {
const files = collection.get('files');
return files && files.filter(f => f?.get('name') === slug).get(0);
},
fields(collection: Collection, slug: string) {
const file = this.fileForEntry(collection, slug);
return file && file.get('fields');
},
entryPath(collection: Collection, slug: string) {
const file = this.fileForEntry(collection, slug);
return file && file.get('file');
},
entrySlug(collection: Collection, path: string) {
const file = (collection.get('files') as CollectionFiles)
.filter(f => f?.get('file') === path)
.get(0);
return file && file.get('name');
},
entryLabel(collection: Collection, slug: string) {
const path = this.entryPath(collection, slug);
const files = collection.get('files');
return files && files.find(f => f?.get('file') === path).get('label');
},
listMethod() {
return ListMethod.ENTRIES_BY_FILES;
},
allowNewEntries() {
return false;
},
allowDeletion(collection: Collection) {
return collection.get('delete', false);
},
templateName(collection: Collection, slug: string) {
return slug;
},
},
};
export const selectFields = (collection: Collection, slug: string) =>
selectors[collection.get('type')].fields(collection, slug);
export const selectFolderEntryExtension = (collection: Collection) =>
selectors[FOLDER].entryExtension(collection);
export const selectFileEntryLabel = (collection: Collection, slug: string) =>
selectors[FILES].entryLabel(collection, slug);
export const selectEntryPath = (collection: Collection, slug: string) =>
selectors[collection.get('type')].entryPath(collection, slug);
export const selectEntrySlug = (collection: Collection, path: string) =>
selectors[collection.get('type')].entrySlug(collection, path);
export const selectListMethod = (collection: Collection) =>
selectors[collection.get('type')].listMethod();
export const selectAllowNewEntries = (collection: Collection) =>
selectors[collection.get('type')].allowNewEntries(collection);
export const selectAllowDeletion = (collection: Collection) =>
selectors[collection.get('type')].allowDeletion(collection);
export const selectTemplateName = (collection: Collection, slug: string) =>
selectors[collection.get('type')].templateName(collection, slug);
export const selectIdentifier = (collection: Collection) => {
const identifier = collection.get('identifier_field');
const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : IDENTIFIER_FIELDS;
const fieldNames = collection.get('fields', List<EntryField>()).map(field => field?.get('name'));
return identifierFields.find(id =>
fieldNames.find(name => name?.toLowerCase().trim() === id.toLowerCase().trim()),
);
};
export const selectInferedField = (collection: Collection, fieldName: string) => {
if (fieldName === 'title' && collection.get('identifier_field')) {
return selectIdentifier(collection);
}
const inferableField = (INFERABLE_FIELDS as Record<
string,
{
type: string;
synonyms: string[];
secondaryTypes: string[];
fallbackToFirstField: boolean;
showError: boolean;
}
>)[fieldName];
const fields = collection.get('fields');
let field;
// If collection has no fields or fieldName is not defined within inferables list, return null
if (!fields || !inferableField) return null;
// Try to return a field of the specified type with one of the synonyms
const mainTypeFields = fields
.filter(f => f?.get('widget', 'string') === inferableField.type)
.map(f => f?.get('name'));
field = mainTypeFields.filter(f => inferableField.synonyms.indexOf(f as string) !== -1);
if (field && field.size > 0) return field.first();
// Try to return a field for each of the specified secondary types
const secondaryTypeFields = fields
.filter(f => inferableField.secondaryTypes.indexOf(f?.get('widget', 'string') as string) !== -1)
.map(f => f?.get('name'));
field = secondaryTypeFields.filter(f => inferableField.synonyms.indexOf(f as string) !== -1);
if (field && field.size > 0) return field.first();
// Try to return the first field of the specified type
if (inferableField.fallbackToFirstField && mainTypeFields.size > 0) return mainTypeFields.first();
// Coundn't infer the field. Show error and return null.
if (inferableField.showError) {
consoleError(
`The Field ${fieldName} is missing for the collection “${collection.get('name')}`,
`Netlify CMS tries to infer the entry ${fieldName} automatically, but one couldn't be found for entries of the collection “${collection.get(
'name',
)}”. Please check your site configuration.`,
);
}
return null;
};
export default collections;

View File

@ -1,7 +1,8 @@
import { Map } from 'immutable';
import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE, CONFIG_MERGE } from 'Actions/config';
import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE, CONFIG_MERGE } from '../actions/config';
import { Config, ConfigAction } from '../types/redux';
const config = (state = Map({ isFetching: true }), action) => {
const config = (state = Map({ isFetching: true }), action: ConfigAction) => {
switch (action.type) {
case CONFIG_MERGE:
return state.mergeDeep(action.payload);
@ -21,4 +22,6 @@ const config = (state = Map({ isFetching: true }), action) => {
}
};
export const selectLocale = (state: Config) => state.get('locale', 'en') as string;
export default config;

View File

@ -86,7 +86,7 @@ const unpublishedEntries = (state = Map(), action) => {
// Update Optimistically
return state.deleteIn([
'entities',
`${action.payload.collection}.${action.payload.entry.get('slug')}`,
`${action.payload.collection}.${action.payload.slug}`,
'isPersisting',
]);
@ -150,4 +150,11 @@ export const selectUnpublishedSlugs = (state, collection) => {
.valueSeq();
};
export const selectEditingWorkflowDraft = state => {
const entry = state.entryDraft.get('entry');
const useWorkflow = state.config.get('publish_mode') === EDITORIAL_WORKFLOW;
const workflowDraft = entry && !entry.isEmpty() && useWorkflow;
return workflowDraft;
};
export default unpublishedEntries;

View File

@ -1,115 +0,0 @@
import { Map, List, fromJS } from 'immutable';
import {
ENTRY_REQUEST,
ENTRY_SUCCESS,
ENTRY_FAILURE,
ENTRIES_REQUEST,
ENTRIES_SUCCESS,
ENTRIES_FAILURE,
ENTRY_DELETE_SUCCESS,
} from 'Actions/entries';
import { SEARCH_ENTRIES_SUCCESS } from 'Actions/search';
let collection;
let loadedEntries;
let append;
let page;
let slug;
const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
switch (action.type) {
case ENTRY_REQUEST:
return state.setIn(
['entities', `${action.payload.collection}.${action.payload.slug}`, 'isFetching'],
true,
);
case ENTRY_SUCCESS:
collection = action.payload.collection;
slug = action.payload.entry.slug;
return state.withMutations(map => {
map.setIn(['entities', `${collection}.${slug}`], fromJS(action.payload.entry));
const ids = map.getIn(['pages', collection, 'ids'], List());
if (!ids.includes(slug)) {
map.setIn(['pages', collection, 'ids'], ids.unshift(slug));
}
});
case ENTRIES_REQUEST:
return state.setIn(['pages', action.payload.collection, 'isFetching'], true);
case ENTRIES_SUCCESS:
collection = action.payload.collection;
loadedEntries = action.payload.entries;
append = action.payload.append;
page = action.payload.page;
return state.withMutations(map => {
loadedEntries.forEach(entry =>
map.setIn(
['entities', `${collection}.${entry.slug}`],
fromJS(entry).set('isFetching', false),
),
);
const ids = List(loadedEntries.map(entry => entry.slug));
map.setIn(
['pages', collection],
Map({
page,
ids: append ? map.getIn(['pages', collection, 'ids'], List()).concat(ids) : ids,
}),
);
});
case ENTRIES_FAILURE:
return state.setIn(['pages', action.meta.collection, 'isFetching'], false);
case ENTRY_FAILURE:
return state.withMutations(map => {
map.setIn(
['entities', `${action.payload.collection}.${action.payload.slug}`, 'isFetching'],
false,
);
map.setIn(
['entities', `${action.payload.collection}.${action.payload.slug}`, 'error'],
action.payload.error.message,
);
});
case SEARCH_ENTRIES_SUCCESS:
loadedEntries = action.payload.entries;
return state.withMutations(map => {
loadedEntries.forEach(entry =>
map.setIn(
['entities', `${entry.collection}.${entry.slug}`],
fromJS(entry).set('isFetching', false),
),
);
});
case ENTRY_DELETE_SUCCESS:
return state.withMutations(map => {
map.deleteIn(['entities', `${action.payload.collectionName}.${action.payload.entrySlug}`]);
map.updateIn(['pages', action.payload.collectionName, 'ids'], ids =>
ids.filter(id => id !== action.payload.entrySlug),
);
});
default:
return state;
}
};
export const selectEntry = (state, collection, slug) =>
state.getIn(['entities', `${collection}.${slug}`]);
export const selectPublishedSlugs = (state, collection) =>
state.getIn(['pages', collection, 'ids'], List());
export const selectEntries = (state, collection) => {
const slugs = selectPublishedSlugs(state, collection);
return slugs && slugs.map(slug => selectEntry(state, collection, slug));
};
export default entries;

View File

@ -0,0 +1,200 @@
import { Map, List, fromJS } from 'immutable';
import { dirname, join } from 'path';
import {
ENTRY_REQUEST,
ENTRY_SUCCESS,
ENTRY_FAILURE,
ENTRIES_REQUEST,
ENTRIES_SUCCESS,
ENTRIES_FAILURE,
ENTRY_DELETE_SUCCESS,
} from '../actions/entries';
import { SEARCH_ENTRIES_SUCCESS } from '../actions/search';
import {
EntriesAction,
EntryRequestPayload,
EntrySuccessPayload,
EntriesSuccessPayload,
EntryObject,
Entries,
Config,
Collection,
EntryFailurePayload,
EntryDeletePayload,
EntriesRequestPayload,
} from '../types/redux';
import { isAbsolutePath, basename } from 'netlify-cms-lib-util/src';
import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
let collection: string;
let loadedEntries: EntryObject[];
let append: boolean;
let page: number;
let slug: string;
const entries = (state = Map({ entities: Map(), pages: Map() }), action: EntriesAction) => {
switch (action.type) {
case ENTRY_REQUEST: {
const payload = action.payload as EntryRequestPayload;
return state.setIn(['entities', `${payload.collection}.${payload.slug}`, 'isFetching'], true);
}
case ENTRY_SUCCESS: {
const payload = action.payload as EntrySuccessPayload;
collection = payload.collection;
slug = payload.entry.slug;
return state.withMutations(map => {
map.setIn(['entities', `${collection}.${slug}`], fromJS(payload.entry));
const ids = map.getIn(['pages', collection, 'ids'], List());
if (!ids.includes(slug)) {
map.setIn(['pages', collection, 'ids'], ids.unshift(slug));
}
});
}
case ENTRIES_REQUEST: {
const payload = action.payload as EntriesRequestPayload;
return state.setIn(['pages', payload.collection, 'isFetching'], true);
}
case ENTRIES_SUCCESS: {
const payload = action.payload as EntriesSuccessPayload;
collection = payload.collection;
loadedEntries = payload.entries;
append = payload.append;
page = payload.page;
return state.withMutations(map => {
loadedEntries.forEach(entry =>
map.setIn(
['entities', `${collection}.${entry.slug}`],
fromJS(entry).set('isFetching', false),
),
);
const ids = List(loadedEntries.map(entry => entry.slug));
map.setIn(
['pages', collection],
Map({
page,
ids: append ? map.getIn(['pages', collection, 'ids'], List()).concat(ids) : ids,
}),
);
});
}
case ENTRIES_FAILURE:
return state.setIn(['pages', action.meta.collection, 'isFetching'], false);
case ENTRY_FAILURE: {
const payload = action.payload as EntryFailurePayload;
return state.withMutations(map => {
map.setIn(['entities', `${payload.collection}.${payload.slug}`, 'isFetching'], false);
map.setIn(
['entities', `${payload.collection}.${payload.slug}`, 'error'],
payload.error.message,
);
});
}
case SEARCH_ENTRIES_SUCCESS: {
const payload = action.payload as EntriesSuccessPayload;
loadedEntries = payload.entries;
return state.withMutations(map => {
loadedEntries.forEach(entry =>
map.setIn(
['entities', `${entry.collection}.${entry.slug}`],
fromJS(entry).set('isFetching', false),
),
);
});
}
case ENTRY_DELETE_SUCCESS: {
const payload = action.payload as EntryDeletePayload;
return state.withMutations(map => {
map.deleteIn(['entities', `${payload.collectionName}.${payload.entrySlug}`]);
map.updateIn(['pages', payload.collectionName, 'ids'], (ids: string[]) =>
ids.filter(id => id !== payload.entrySlug),
);
});
}
default:
return state;
}
};
export const selectEntry = (state: Entries, collection: string, slug: string) =>
state.getIn(['entities', `${collection}.${slug}`]);
export const selectPublishedSlugs = (state: Entries, collection: string) =>
state.getIn(['pages', collection, 'ids'], List<string>());
export const selectEntries = (state: Entries, collection: string) => {
const slugs = selectPublishedSlugs(state, collection);
return slugs && slugs.map(slug => selectEntry(state, collection, slug as string));
};
const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES';
export const selectMediaFolder = (
config: Config,
collection: Collection | null,
entryPath: string | null,
) => {
let mediaFolder = config.get('media_folder');
const useWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
if (useWorkflow && collection && collection.has('media_folder')) {
if (entryPath) {
const entryDir = dirname(entryPath);
mediaFolder = join(entryDir, collection.get('media_folder') as string);
} else {
mediaFolder = join(collection.get('folder') as string, DRAFT_MEDIA_FILES);
}
}
return mediaFolder;
};
export const selectMediaFilePath = (
config: Config,
collection: Collection | null,
entryPath: string | null,
mediaPath: string,
) => {
if (isAbsolutePath(mediaPath)) {
return mediaPath;
}
let mediaFolder;
if (mediaPath.startsWith('/')) {
// absolute media paths are not bound to a collection
mediaFolder = selectMediaFolder(config, null, null);
} else {
mediaFolder = selectMediaFolder(config, collection, entryPath);
}
return join(mediaFolder, basename(mediaPath));
};
export const selectMediaFilePublicPath = (
config: Config,
collection: Collection | null,
mediaPath: string,
) => {
if (isAbsolutePath(mediaPath)) {
return mediaPath;
}
let publicFolder = config.get('public_folder');
const useWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
if (useWorkflow && collection && collection.has('media_folder')) {
publicFolder = collection.get('media_folder') as string;
}
return join(publicFolder, basename(mediaPath));
};
export default entries;

View File

@ -15,9 +15,7 @@ import {
ENTRY_PERSIST_FAILURE,
ENTRY_DELETE_SUCCESS,
ADD_DRAFT_ENTRY_MEDIA_FILE,
SET_DRAFT_ENTRY_MEDIA_FILES,
REMOVE_DRAFT_ENTRY_MEDIA_FILE,
CLEAR_DRAFT_ENTRY_MEDIA_FILES,
} from 'Actions/entries';
import {
UNPUBLISHED_ENTRY_PERSIST_REQUEST,
@ -27,7 +25,6 @@ import {
const initialState = Map({
entry: Map(),
mediaFiles: List(),
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
@ -41,7 +38,6 @@ const entryDraftReducer = (state = Map(), action) => {
return state.withMutations(state => {
state.set('entry', action.payload.entry);
state.setIn(['entry', 'newRecord'], false);
state.set('mediaFiles', action.payload.mediaFiles || List());
// An existing entry may already have metadata. If we surfed away and back to its
// editor page, the metadata will have been fetched already, so we shouldn't
// clear it as to not break relation lists.
@ -55,7 +51,6 @@ const entryDraftReducer = (state = Map(), action) => {
return state.withMutations(state => {
state.set('entry', fromJS(action.payload));
state.setIn(['entry', 'newRecord'], true);
state.set('mediaFiles', List());
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', false);
@ -69,7 +64,6 @@ const entryDraftReducer = (state = Map(), action) => {
state.delete('localBackup');
state.set('entry', backupEntry);
state.setIn(['entry', 'newRecord'], !backupEntry.get('path'));
state.set('mediaFiles', backupDraftEntry.get('mediaFiles'));
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', true);
@ -88,10 +82,9 @@ const entryDraftReducer = (state = Map(), action) => {
case DRAFT_DISCARD:
return initialState;
case DRAFT_LOCAL_BACKUP_RETRIEVED: {
const { entry, mediaFiles } = action.payload;
const { entry } = action.payload;
const newState = new Map({
entry: fromJS(entry),
mediaFiles: List(mediaFiles),
});
return state.set('localBackup', newState);
}
@ -139,28 +132,31 @@ const entryDraftReducer = (state = Map(), action) => {
state.set('hasChanged', false);
});
case ADD_DRAFT_ENTRY_MEDIA_FILE:
if (state.has('mediaFiles')) {
return state.update('mediaFiles', list =>
list.filterNot(file => file.id === action.payload.id).push({ ...action.payload }),
);
}
return state;
case ADD_DRAFT_ENTRY_MEDIA_FILE: {
return state.withMutations(state => {
const mediaFiles = state.getIn(['entry', 'mediaFiles']);
case SET_DRAFT_ENTRY_MEDIA_FILES: {
return state.set('mediaFiles', List(action.payload));
state.setIn(
['entry', 'mediaFiles'],
mediaFiles
.filterNot(file => file.get('id') === action.payload.id)
.insert(0, fromJS(action.payload)),
);
state.set('hasChanged', true);
});
}
case REMOVE_DRAFT_ENTRY_MEDIA_FILE:
if (state.has('mediaFiles')) {
return state.update('mediaFiles', list =>
list.filterNot(file => file.id === action.payload.id),
);
}
return state;
case REMOVE_DRAFT_ENTRY_MEDIA_FILE: {
return state.withMutations(state => {
const mediaFiles = state.getIn(['entry', 'mediaFiles']);
case CLEAR_DRAFT_ENTRY_MEDIA_FILES:
return state.set('mediaFiles', List());
state.setIn(
['entry', 'mediaFiles'],
mediaFiles.filterNot(file => file.get('id') === action.payload.id),
);
state.set('hasChanged', true);
});
}
default:
return state;

View File

@ -7,10 +7,12 @@ import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow';
import entryDraft from './entryDraft';
import collections from './collections';
import search from './search';
import medias from './medias';
import mediaLibrary from './mediaLibrary';
import medias, * as fromMedias from './medias';
import deploys, * as fromDeploys from './deploys';
import globalUI from './globalUI';
import { Status } from '../constants/publishModes';
import { State } from '../types/redux';
const reducers = {
auth,
@ -22,8 +24,8 @@ const reducers = {
cursors,
editorialWorkflow,
entryDraft,
mediaLibrary,
medias,
mediaLibrary,
deploys,
globalUI,
};
@ -33,16 +35,16 @@ export default reducers;
/*
* Selectors
*/
export const selectEntry = (state, collection, slug) =>
export const selectEntry = (state: State, collection: string, slug: string) =>
fromEntries.selectEntry(state.entries, collection, slug);
export const selectEntries = (state, collection) =>
export const selectEntries = (state: State, collection: string) =>
fromEntries.selectEntries(state.entries, collection);
export const selectPublishedSlugs = (state, collection) =>
export const selectPublishedSlugs = (state: State, collection: string) =>
fromEntries.selectPublishedSlugs(state.entries, collection);
export const selectSearchedEntries = state => {
export const selectSearchedEntries = (state: State) => {
const searchItems = state.search.get('entryIds');
return (
searchItems &&
@ -52,27 +54,17 @@ export const selectSearchedEntries = state => {
);
};
export const selectDeployPreview = (state, collection, slug) =>
export const selectDeployPreview = (state: State, collection: string, slug: string) =>
fromDeploys.selectDeployPreview(state.deploys, collection, slug);
export const selectUnpublishedEntry = (state, collection, slug) =>
export const selectUnpublishedEntry = (state: State, collection: string, slug: string) =>
fromEditorialWorkflow.selectUnpublishedEntry(state.editorialWorkflow, collection, slug);
export const selectUnpublishedEntriesByStatus = (state, status) =>
export const selectUnpublishedEntriesByStatus = (state: State, status: Status) =>
fromEditorialWorkflow.selectUnpublishedEntriesByStatus(state.editorialWorkflow, status);
export const selectUnpublishedSlugs = (state, collection) =>
export const selectUnpublishedSlugs = (state: State, collection: string) =>
fromEditorialWorkflow.selectUnpublishedSlugs(state.editorialWorkflow, collection);
export const selectIntegration = (state, collection, hook) =>
export const selectIntegration = (state: State, collection: string | null, hook: string) =>
fromIntegrations.selectIntegration(state.integrations, collection, hook);
export const getAsset = (state, path) => {
/**
* If an external media library is in use, just return the path.
*/
if (state.mediaLibrary.get('externalLibrary')) {
return path;
}
return fromMedias.getAsset(state.config.get('public_folder'), state.medias, path);
};

View File

@ -1,10 +1,16 @@
import { fromJS, List } from 'immutable';
import { CONFIG_SUCCESS } from 'Actions/config';
import { CONFIG_SUCCESS } from '../actions/config';
import { Integrations, IntegrationsAction, Integration } from '../types/redux';
const integrations = (state = null, action) => {
interface Acc {
providers: Record<string, {}>;
hooks: Record<string, string | Record<string, string>>;
}
const integrations = (state = null, action: IntegrationsAction): Integrations | null => {
switch (action.type) {
case CONFIG_SUCCESS: {
const integrations = action.payload.get('integrations', List()).toJS() || [];
const integrations: Integration[] = action.payload.get('integrations', List()).toJS() || [];
const newState = integrations.reduce(
(acc, integration) => {
const { hooks, collections, provider, ...providerData } = integration;
@ -17,18 +23,18 @@ const integrations = (state = null, action) => {
}
const integrationCollections =
collections === '*'
? action.payload.collections.map(collection => collection.name)
: collections;
? action.payload.get('collections').map(collection => collection.get('name'))
: (collections as string[]);
integrationCollections.forEach(collection => {
hooks.forEach(hook => {
acc.hooks[collection]
? (acc.hooks[collection][hook] = provider)
? ((acc.hooks[collection] as Record<string, string>)[hook] = provider)
: (acc.hooks[collection] = { [hook]: provider });
});
});
return acc;
},
{ providers: {}, hooks: {} },
{ providers: {}, hooks: {} } as Acc,
);
return fromJS(newState);
}
@ -37,7 +43,7 @@ const integrations = (state = null, action) => {
}
};
export const selectIntegration = (state, collection, hook) =>
export const selectIntegration = (state: Integrations, collection: string | null, hook: string) =>
collection
? state.getIn(['hooks', collection, hook], false)
: state.getIn(['hooks', hook], false);

View File

@ -1,6 +1,5 @@
import { Map } from 'immutable';
import { Map, List } from 'immutable';
import uuid from 'uuid/v4';
import { differenceBy } from 'lodash';
import {
MEDIA_LIBRARY_OPEN,
MEDIA_LIBRARY_CLOSE,
@ -19,8 +18,9 @@ import {
MEDIA_DISPLAY_URL_REQUEST,
MEDIA_DISPLAY_URL_SUCCESS,
MEDIA_DISPLAY_URL_FAILURE,
ADD_MEDIA_FILES_TO_LIBRARY,
} from 'Actions/mediaLibrary';
import { selectEditingWorkflowDraft } from 'Reducers/editorialWorkflow';
import { selectIntegration } from 'Reducers';
const defaultState = {
isVisible: false,
@ -129,12 +129,6 @@ const mediaLibrary = (state = Map(defaultState), action) => {
map.set('isPersisting', false);
});
}
case ADD_MEDIA_FILES_TO_LIBRARY: {
const { mediaFiles } = action.payload;
let updatedFiles = differenceBy(state.get('files'), mediaFiles, 'path');
updatedFiles = [...mediaFiles.map(file => ({ ...file, key: uuid() })), ...updatedFiles];
return state.set('files', updatedFiles);
}
case MEDIA_PERSIST_FAILURE: {
const privateUploadChanged = state.get('privateUpload') !== action.payload.privateUpload;
if (privateUploadChanged) {
@ -193,4 +187,33 @@ const mediaLibrary = (state = Map(defaultState), action) => {
}
};
export function selectMediaFiles(state) {
const { mediaLibrary, entryDraft } = state;
const workflowDraft = selectEditingWorkflowDraft(state);
const integration = selectIntegration(state, null, 'assetStore');
let files;
if (workflowDraft && !integration) {
files = entryDraft
.getIn(['entry', 'mediaFiles'], List())
.toJS()
.map(file => ({ key: file.id, ...file }));
} else {
files = mediaLibrary.get('files') || [];
}
return files;
}
export function selectMediaFileByPath(state, path) {
const files = selectMediaFiles(state);
const file = files.find(file => file.path === path);
return file;
}
export function selectMediaDisplayURL(state, id) {
const displayUrlState = state.mediaLibrary.getIn(['displayURLs', id], Map());
return displayUrlState;
}
export default mediaLibrary;

View File

@ -1,41 +0,0 @@
import { Map } from 'immutable';
import { resolvePath } from 'netlify-cms-lib-util';
import { ADD_ASSETS, ADD_ASSET, REMOVE_ASSET } from 'Actions/media';
import AssetProxy from 'ValueObjects/AssetProxy';
const medias = (state = Map(), action) => {
switch (action.type) {
case ADD_ASSETS: {
let newState = state;
action.payload.forEach(asset => {
newState = newState.set(asset.public_path, asset);
});
return newState;
}
case ADD_ASSET:
return state.set(action.payload.public_path, action.payload);
case REMOVE_ASSET:
return state.delete(action.payload);
default:
return state;
}
};
export default medias;
const memoizedProxies = {};
export const getAsset = (publicFolder, state, path) => {
// No path provided, skip
if (!path) return null;
let proxy = state.get(path) || memoizedProxies[path];
if (proxy) {
// There is already an AssetProxy in memory for this path. Use it.
return proxy;
}
// Create a new AssetProxy (for consistency) and return it.
proxy = memoizedProxies[path] = new AssetProxy(resolvePath(path, publicFolder), null, true);
return proxy;
};

View File

@ -0,0 +1,29 @@
import { fromJS } from 'immutable';
import { ADD_ASSETS, ADD_ASSET, REMOVE_ASSET } from '../actions/media';
import AssetProxy from '../valueObjects/AssetProxy';
import { Medias, MediasAction } from '../types/redux';
const medias = (state: Medias = fromJS({}), action: MediasAction) => {
switch (action.type) {
case ADD_ASSETS: {
const payload = action.payload as AssetProxy[];
let newState = state;
payload.forEach(asset => {
newState = newState.set(asset.path, asset);
});
return newState;
}
case ADD_ASSET: {
const payload = action.payload as AssetProxy;
return state.set(payload.path, payload);
}
case REMOVE_ASSET: {
const payload = action.payload as string;
return state.delete(payload);
}
default:
return state;
}
};
export default medias;