feat: commit media with post (#2851)

* feat: commit media with post - initial commit

* feat: add draft media indication

* feat: sync UI media files with GitHub on entry load

* feat: bug fixes

* feat: delete media files from github when removed from library

* test: add GitHub backend tests

* test: add unit tests

* fix: meta data object files are not updated

* feat: used nested paths when update a tree instead of recursion

* feat(test-backend): update test backend to persist media file with entry

* test(e2e): re-record fixtures data

* chore: code cleanup

* chore: code cleanup

* fix: wait for library to load before adding entry media files

* chore: code cleanup

* fix: don't add media files on entry when not a draft

* fix: sync media library after draft entry was published

* feat: update media library card draft style, add tests

* test: add Editor unit tests

* chore: test code cleanup

* fix: publishing an entry from workflow tab throws an error

* fix: duplicate media files when using test backend

* refactor: fix lodash import

* chore: update translations and yarn file after rebase

* test(cypress): update recorded data

* fix(test-backend): fix mapping of media files on publish
This commit is contained in:
Erez Rokah
2019-11-17 11:51:50 +02:00
committed by GitHub
parent 0898767fc9
commit 6515dee871
77 changed files with 17692 additions and 27991 deletions

View File

@ -1,7 +1,10 @@
import { resolveBackend } from '../backend';
import { resolveBackend, Backend } from '../backend';
import registry from 'Lib/registry';
import { Map, List } from 'immutable';
jest.mock('Lib/registry');
jest.mock('netlify-cms-lib-util');
jest.mock('Formats/formats');
const configWrapper = inputObject => ({
get: prop => inputObject[prop],
@ -108,4 +111,271 @@ describe('Backend', () => {
expect(result.length).toBe(1);
});
});
describe('getLocalDraftBackup', () => {
const { localForage } = require('netlify-cms-lib-util');
beforeEach(() => {
jest.clearAllMocks();
});
it('should return empty object on no item', async () => {
const implementation = {
init: jest.fn(() => implementation),
};
const config = Map({});
const backend = new Backend(implementation, { config, backendName: 'github' });
const collection = Map({
name: 'posts',
});
const slug = 'slug';
localForage.getItem.mockReturnValue();
const result = await backend.getLocalDraftBackup(collection, slug);
expect(result).toEqual({});
expect(localForage.getItem).toHaveBeenCalledTimes(1);
expect(localForage.getItem).toHaveBeenCalledWith('backup.posts.slug');
});
it('should return empty object on item with empty content', async () => {
const implementation = {
init: jest.fn(() => implementation),
};
const config = Map({});
const backend = new Backend(implementation, { config, backendName: 'github' });
const collection = Map({
name: 'posts',
});
const slug = 'slug';
localForage.getItem.mockReturnValue({ raw: '' });
const result = await backend.getLocalDraftBackup(collection, slug);
expect(result).toEqual({});
expect(localForage.getItem).toHaveBeenCalledTimes(1);
expect(localForage.getItem).toHaveBeenCalledWith('backup.posts.slug');
});
it('should return backup entry, empty media files and assets when only raw property was saved', async () => {
const implementation = {
init: jest.fn(() => implementation),
};
const config = Map({});
const backend = new Backend(implementation, { config, backendName: 'github' });
const collection = Map({
name: 'posts',
});
const slug = 'slug';
localForage.getItem.mockReturnValue({
raw: 'content',
});
const result = await backend.getLocalDraftBackup(collection, slug);
expect(result).toEqual({
assets: [],
mediaFiles: [],
entry: {
collection: 'posts',
slug: 'slug',
path: '',
partial: false,
raw: 'content',
data: {},
label: null,
metaData: null,
isModification: null,
},
});
expect(localForage.getItem).toHaveBeenCalledTimes(1);
expect(localForage.getItem).toHaveBeenCalledWith('backup.posts.slug');
});
it('should return backup entry, media files and assets when all were backed up', async () => {
const implementation = {
init: jest.fn(() => implementation),
};
const config = Map({});
const backend = new Backend(implementation, { config, backendName: 'github' });
const collection = Map({
name: 'posts',
});
const slug = 'slug';
localForage.getItem.mockReturnValue({
raw: 'content',
mediaFiles: [{ id: '1' }],
assets: [{ public_path: 'public_path' }],
});
const result = await backend.getLocalDraftBackup(collection, slug);
expect(result).toEqual({
assets: [{ public_path: 'public_path' }],
mediaFiles: [{ id: '1' }],
entry: {
collection: 'posts',
slug: 'slug',
path: '',
partial: false,
raw: 'content',
data: {},
label: null,
metaData: null,
isModification: null,
},
});
expect(localForage.getItem).toHaveBeenCalledTimes(1);
expect(localForage.getItem).toHaveBeenCalledWith('backup.posts.slug');
});
});
describe('persistLocalDraftBackup', () => {
const { localForage } = require('netlify-cms-lib-util');
beforeEach(() => {
jest.clearAllMocks();
});
it('should not persist empty entry', async () => {
const implementation = {
init: jest.fn(() => implementation),
};
const config = Map({});
const backend = new Backend(implementation, { config, backendName: 'github' });
backend.entryToRaw = jest.fn().mockReturnValue('');
const collection = Map({
name: 'posts',
});
const slug = 'slug';
const entry = Map({
slug,
});
await backend.persistLocalDraftBackup(entry, collection, List(), List());
expect(backend.entryToRaw).toHaveBeenCalledTimes(1);
expect(backend.entryToRaw).toHaveBeenCalledWith(collection, entry);
expect(localForage.setItem).toHaveBeenCalledTimes(0);
});
it('should persist non empty entry', async () => {
const implementation = {
init: jest.fn(() => implementation),
};
const config = Map({});
const backend = new Backend(implementation, { config, backendName: 'github' });
backend.entryToRaw = jest.fn().mockReturnValue('content');
const collection = Map({
name: 'posts',
});
const slug = 'slug';
const entry = Map({
slug,
path: 'content/posts/entry.md',
});
const mediaFiles = List([{ id: '1' }]);
const assets = List([{ public_path: 'public_path' }]);
await backend.persistLocalDraftBackup(entry, collection, mediaFiles, assets);
expect(backend.entryToRaw).toHaveBeenCalledTimes(1);
expect(backend.entryToRaw).toHaveBeenCalledWith(collection, entry);
expect(localForage.setItem).toHaveBeenCalledTimes(2);
expect(localForage.setItem).toHaveBeenCalledWith('backup.posts.slug', {
assets: [{ public_path: 'public_path' }],
mediaFiles: [{ id: '1' }],
path: 'content/posts/entry.md',
raw: 'content',
});
expect(localForage.setItem).toHaveBeenCalledWith('backup', 'content');
});
});
describe('persistMedia', () => {
it('should persist media', async () => {
const persistMediaResult = {};
const implementation = {
init: jest.fn(() => implementation),
persistMedia: jest.fn().mockResolvedValue(persistMediaResult),
};
const config = Map({});
const user = { login: 'login', name: 'name' };
const backend = new Backend(implementation, { config, backendName: 'github' });
backend.currentUser = jest.fn().mockResolvedValue(user);
const file = { path: 'static/media/image.png' };
const result = await backend.persistMedia(config, file, true);
expect(result).toBe(persistMediaResult);
expect(implementation.persistMedia).toHaveBeenCalledTimes(1);
expect(implementation.persistMedia).toHaveBeenCalledWith(
{ path: 'static/media/image.png' },
{ commitMessage: 'Upload “static/media/image.png”', draft: true },
);
});
});
describe('unpublishedEntry', () => {
it('should return unpublished entry', async () => {
const unpublishedEntryResult = {
file: { path: 'path' },
isModification: true,
metaData: {},
mediaFiles: [{ id: '1' }],
data: 'content',
};
const implementation = {
init: jest.fn(() => implementation),
unpublishedEntry: jest.fn().mockResolvedValue(unpublishedEntryResult),
};
const config = Map({});
const backend = new Backend(implementation, { config, backendName: 'github' });
const collection = Map({
name: 'posts',
});
const slug = 'slug';
const result = await backend.unpublishedEntry(collection, slug);
expect(result).toEqual({
collection: 'draft',
slug: '',
path: 'path',
partial: false,
raw: 'content',
data: {},
label: null,
metaData: {},
isModification: true,
mediaFiles: [{ id: '1' }],
});
});
});
});

View File

@ -0,0 +1,230 @@
import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
import * as actions from '../editorialWorkflow';
import { setDraftEntryMediaFiles } from '../entries';
import { addAssets } from '../media';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fromJS } from 'immutable';
jest.mock('coreSrc/backend');
jest.mock('Reducers', () => {
return {
getAsset: jest.fn().mockReturnValue({}),
};
});
jest.mock('ValueObjects/AssetProxy');
jest.mock('netlify-cms-lib-util');
jest.mock('uuid/v4', () => {
return jest.fn().mockReturnValue('000000000000000000000');
});
jest.mock('redux-notifications', () => {
const actual = jest.requireActual('redux-notifications');
return {
...actual,
actions: {
notifSend: jest.fn().mockImplementation(payload => ({
type: 'NOTIF_SEND',
...payload,
})),
},
};
});
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('editorialWorkflow actions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('loadUnpublishedEntry', () => {
it('should load unpublished entry', () => {
const { currentBackend } = require('coreSrc/backend');
const { createAssetProxy } = require('ValueObjects/AssetProxy');
const assetProxy = { name: 'name', public_path: 'public_path' };
const entry = { mediaFiles: [{ file: { name: 'name' }, id: '1' }] };
const backend = {
unpublishedEntry: jest.fn().mockResolvedValue(entry),
};
const store = mockStore({
config: fromJS({}),
collections: fromJS({
posts: { name: 'posts' },
}),
mediaLibrary: fromJS({
isLoading: false,
}),
});
currentBackend.mockReturnValue(backend);
createAssetProxy.mockResolvedValue(assetProxy);
const slug = 'slug';
const collection = store.getState().collections.get('posts');
return store.dispatch(actions.loadUnpublishedEntry(collection, slug)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(5);
expect(actions[0]).toEqual({
type: 'UNPUBLISHED_ENTRY_REQUEST',
payload: {
collection: 'posts',
slug,
},
});
expect(actions[1]).toEqual(addAssets([assetProxy]));
expect(actions[2]).toEqual(
setDraftEntryMediaFiles([
{
file: { name: 'name' },
name: 'name',
id: '1',
draft: true,
public_path: 'public_path',
},
]),
);
expect(actions[3]).toEqual({
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
payload: {
mediaFiles: [{ file: { name: 'name' }, id: '1', draft: true }],
},
});
expect(actions[4]).toEqual({
type: 'UNPUBLISHED_ENTRY_SUCCESS',
payload: {
collection: 'posts',
entry,
},
});
});
});
});
describe('publishUnpublishedEntry', () => {
it('should publish unpublished entry and report success', () => {
const { currentBackend } = require('coreSrc/backend');
const mediaFiles = [{ file: { name: 'name' }, id: '1' }];
const entry = { mediaFiles };
const backend = {
publishUnpublishedEntry: jest.fn().mockResolvedValue({ mediaFiles }),
getEntry: jest.fn().mockResolvedValue(entry),
};
const store = mockStore({
config: fromJS({}),
mediaLibrary: fromJS({
isLoading: false,
}),
collections: fromJS({
posts: { name: 'posts' },
}),
});
currentBackend.mockReturnValue(backend);
const slug = 'slug';
return store.dispatch(actions.publishUnpublishedEntry('posts', slug)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(7);
expect(actions[0]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST',
payload: {
collection: 'posts',
slug,
},
optimist: { type: BEGIN, id: '000000000000000000000' },
});
expect(actions[1]).toEqual({
type: 'NOTIF_SEND',
message: { key: 'ui.toast.entryPublished' },
kind: 'success',
dismissAfter: 4000,
});
expect(actions[2]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS',
payload: {
collection: 'posts',
slug,
},
optimist: { type: COMMIT, id: '000000000000000000000' },
});
expect(actions[3]).toEqual({
type: 'ENTRY_REQUEST',
payload: {
slug,
collection: 'posts',
},
});
expect(actions[4]).toEqual({
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
payload: {
mediaFiles: [{ file: { name: 'name' }, id: '1', draft: false }],
},
});
expect(actions[5]).toEqual({
type: 'CLEAR_DRAFT_ENTRY_MEDIA_FILES',
});
expect(actions[6]).toEqual({
type: 'ENTRY_SUCCESS',
payload: {
entry,
collection: 'posts',
},
});
});
});
it('should publish unpublished entry and report error', () => {
const { currentBackend } = require('coreSrc/backend');
const error = new Error('failed to publish entry');
const backend = {
publishUnpublishedEntry: jest.fn().mockRejectedValue(error),
};
const store = mockStore({
config: fromJS({}),
collections: fromJS({
posts: { name: 'posts' },
}),
});
currentBackend.mockReturnValue(backend);
const slug = 'slug';
return store.dispatch(actions.publishUnpublishedEntry('posts', slug)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(3);
expect(actions[0]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST',
payload: {
collection: 'posts',
slug,
},
optimist: { type: BEGIN, id: '000000000000000000000' },
});
expect(actions[1]).toEqual({
type: 'NOTIF_SEND',
message: { key: 'ui.toast.onFailToPublishEntry', details: error },
kind: 'danger',
dismissAfter: 8000,
});
expect(actions[2]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_FAILURE',
payload: {
collection: 'posts',
slug,
},
optimist: { type: REVERT, id: '000000000000000000000' },
});
});
});
});
});

View File

@ -1,5 +1,27 @@
import { fromJS } from 'immutable';
import { createEmptyDraftData } from '../entries';
import { fromJS, List, Map } from 'immutable';
import {
createEmptyDraftData,
retrieveLocalBackup,
persistLocalBackup,
getMediaAssets,
discardDraft,
loadLocalBackup,
} from '../entries';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
jest.mock('coreSrc/backend');
jest.mock('Reducers', () => {
return {
getAsset: jest.fn().mockReturnValue({}),
};
});
jest.mock('ValueObjects/AssetProxy');
jest.mock('netlify-cms-lib-util');
jest.mock('../mediaLibrary.js');
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('entries', () => {
describe('createEmptyDraftData', () => {
@ -79,4 +101,166 @@ describe('entries', () => {
expect(createEmptyDraftData(fields)).toEqual({});
});
});
describe('discardDraft', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should delete media files on discard draft', () => {
const { deleteMedia } = require('../mediaLibrary');
const mediaFiles = [{ draft: false }, { draft: true }];
deleteMedia.mockImplementation(file => ({ type: 'DELETE_MEDIA', payload: file }));
const store = mockStore({
config: Map(),
entryDraft: Map({
mediaFiles: List(mediaFiles),
}),
});
store.dispatch(discardDraft());
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({ type: 'DELETE_MEDIA', payload: { draft: true } });
expect(actions[1]).toEqual({ type: 'DRAFT_DISCARD' });
});
});
describe('persistLocalBackup', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should persist local backup with media files', () => {
const getState = jest.fn();
const { currentBackend } = require('coreSrc/backend');
const { getAsset } = require('Reducers');
const backend = {
persistLocalDraftBackup: jest.fn((...args) => args),
};
const state = { config: {} };
currentBackend.mockReturnValue(backend);
getAsset.mockImplementation((state, path) => path);
getState.mockReturnValue(state);
const entry = Map();
const collection = Map();
const mediaFiles = [{ public_path: '/static/media/image.png' }];
const result = persistLocalBackup(entry, collection, mediaFiles)(null, getState);
expect(result).toEqual([entry, collection, mediaFiles, ['/static/media/image.png']]);
});
});
describe('retrieveLocalBackup', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should retrieve media files with local backup', () => {
const { currentBackend } = require('coreSrc/backend');
const { createAssetProxy } = require('ValueObjects/AssetProxy');
const { addMediaFilesToLibrary } = require('../mediaLibrary');
addMediaFilesToLibrary.mockImplementation(mediaFiles => ({
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
payload: { mediaFiles },
}));
const backend = {
getLocalDraftBackup: jest.fn((...args) => args),
};
const store = mockStore({
config: Map(),
});
currentBackend.mockReturnValue(backend);
createAssetProxy.mockImplementation((value, fileObj) => ({ value, fileObj }));
const collection = Map({
name: 'collection',
});
const slug = 'slug';
const entry = {};
const mediaFiles = [{ public_path: '/static/media/image.png' }];
const assets = [{ value: 'image.png', fileObj: {} }];
backend.getLocalDraftBackup.mockReturnValue({ entry, mediaFiles, assets });
return store.dispatch(retrieveLocalBackup(collection, slug)).then(() => {
const actions = store.getActions();
expect(createAssetProxy).toHaveBeenCalledTimes(1);
expect(createAssetProxy).toHaveBeenCalledWith(assets[0].value, assets[0].fileObj);
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'ADD_ASSETS',
payload: [{ value: 'image.png', fileObj: {} }],
});
expect(actions[1]).toEqual({
type: 'DRAFT_LOCAL_BACKUP_RETRIEVED',
payload: { entry, mediaFiles },
});
});
});
});
describe('loadLocalBackup', () => {
it('should add backup media files to media library', () => {
const store = mockStore({
config: Map(),
entryDraft: Map({
mediaFiles: List([{ path: 'static/media.image.png' }]),
}),
mediaLibrary: Map({
isLoading: false,
}),
});
store.dispatch(loadLocalBackup());
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'DRAFT_CREATE_FROM_LOCAL_BACKUP',
});
expect(actions[1]).toEqual({
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
payload: { mediaFiles: [{ path: 'static/media.image.png', draft: true }] },
});
});
});
describe('getMediaAssets', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should map mediaFiles to assets', () => {
const { getAsset } = require('Reducers');
const state = {};
const mediaFiles = [{ public_path: 'public_path' }];
const asset = { name: 'asset1' };
getAsset.mockReturnValue(asset);
expect(getMediaAssets(state, mediaFiles)).toEqual([asset]);
expect(getAsset).toHaveBeenCalledTimes(1);
expect(getAsset).toHaveBeenCalledWith(state, 'public_path');
});
});
});

View File

@ -1,7 +1,11 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fromJS } from 'immutable';
import { insertMedia } from '../mediaLibrary';
import { fromJS, List, Map } from 'immutable';
import { insertMedia, persistMedia, deleteMedia, addMediaFilesToLibrary } from '../mediaLibrary';
jest.mock('coreSrc/backend');
jest.mock('ValueObjects/AssetProxy');
jest.mock('../waitUntil');
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
@ -110,4 +114,260 @@ describe('mediaLibrary', () => {
}
});
});
const { currentBackend } = require('coreSrc/backend');
const { createAssetProxy } = require('ValueObjects/AssetProxy');
const backend = {
persistMedia: jest.fn(() => ({ id: 'id' })),
deleteMedia: jest.fn(),
};
currentBackend.mockReturnValue(backend);
describe('persistMedia', () => {
global.URL = { createObjectURL: jest.fn().mockReturnValue('displayURL') };
beforeEach(() => {
jest.clearAllMocks();
});
it('should persist media as draft in editorial workflow', () => {
const store = mockStore({
config: Map({
publish_mode: 'editorial_workflow',
}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map({ isPersisting: false }),
}),
});
const file = new File([''], 'name.png');
const assetProxy = { public_path: '/media/name.png' };
createAssetProxy.mockReturnValue(assetProxy);
return store.dispatch(persistMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(4);
expect(actions[0]).toEqual({ type: 'MEDIA_PERSIST_REQUEST' });
expect(actions[1]).toEqual({
type: 'ADD_ASSET',
payload: { public_path: '/media/name.png' },
});
expect(actions[2]).toEqual({
type: 'ADD_DRAFT_ENTRY_MEDIA_FILE',
payload: { draft: true, id: 'id', public_path: '/media/name.png' },
});
expect(actions[3]).toEqual({
type: 'MEDIA_PERSIST_SUCCESS',
payload: {
file: { draft: true, id: 'id', displayURL: 'displayURL' },
},
});
expect(backend.persistMedia).toHaveBeenCalledTimes(1);
expect(backend.persistMedia).toHaveBeenCalledWith(
store.getState().config,
assetProxy,
true,
);
});
});
it('should not persist media as draft when not in editorial workflow', () => {
const store = mockStore({
config: Map({}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map({ isPersisting: false }),
}),
});
const file = new File([''], 'name.png');
const assetProxy = { public_path: '/media/name.png' };
createAssetProxy.mockReturnValue(assetProxy);
return store.dispatch(persistMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(3);
expect(actions[0]).toEqual({ type: 'MEDIA_PERSIST_REQUEST' });
expect(actions[1]).toEqual({
type: 'ADD_ASSET',
payload: { public_path: '/media/name.png' },
});
expect(actions[2]).toEqual({
type: 'MEDIA_PERSIST_SUCCESS',
payload: {
file: { draft: false, id: 'id', displayURL: 'displayURL' },
},
});
expect(backend.persistMedia).toHaveBeenCalledTimes(1);
expect(backend.persistMedia).toHaveBeenCalledWith(
store.getState().config,
assetProxy,
false,
);
});
});
it('should not persist media as draft when draft is empty', () => {
const store = mockStore({
config: Map({
publish_mode: 'editorial_workflow',
}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map(),
}),
});
const file = new File([''], 'name.png');
const assetProxy = { public_path: '/media/name.png' };
createAssetProxy.mockReturnValue(assetProxy);
return store.dispatch(persistMedia(file)).then(() => {
expect(backend.persistMedia).toHaveBeenCalledTimes(1);
expect(backend.persistMedia).toHaveBeenCalledWith(
store.getState().config,
assetProxy,
false,
);
});
});
});
describe('deleteMedia', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should delete non draft file', () => {
const store = mockStore({
config: Map({
publish_mode: 'editorial_workflow',
}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map({ isPersisting: false }),
}),
});
const file = { name: 'name.png', id: 'id', path: 'static/media/name.png', draft: false };
const assetProxy = { public_path: '/media/name.png' };
createAssetProxy.mockReturnValue(assetProxy);
return store.dispatch(deleteMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(4);
expect(actions[0]).toEqual({ type: 'MEDIA_DELETE_REQUEST' });
expect(actions[1]).toEqual({
type: 'REMOVE_ASSET',
payload: '/media/name.png',
});
expect(actions[2]).toEqual({
type: 'REMOVE_DRAFT_ENTRY_MEDIA_FILE',
payload: { id: 'id' },
});
expect(actions[3]).toEqual({
type: 'MEDIA_DELETE_SUCCESS',
payload: { file },
});
expect(backend.deleteMedia).toHaveBeenCalledTimes(1);
expect(backend.deleteMedia).toHaveBeenCalledWith(
store.getState().config,
'static/media/name.png',
);
});
});
it('should not delete a draft file', () => {
const store = mockStore({
config: Map({
publish_mode: 'editorial_workflow',
}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map({ isPersisting: false }),
}),
});
const file = { name: 'name.png', id: 'id', path: 'static/media/name.png', draft: true };
const assetProxy = { public_path: '/media/name.png' };
createAssetProxy.mockReturnValue(assetProxy);
return store.dispatch(deleteMedia(file)).then(() => {
expect(backend.deleteMedia).toHaveBeenCalledTimes(0);
});
});
});
describe('addMediaFilesToLibrary', () => {
it('should not wait if media library is loaded', () => {
const store = mockStore({
mediaLibrary: Map({
isLoading: false,
}),
});
const mediaFiles = [{ id: '1' }];
store.dispatch(addMediaFilesToLibrary(mediaFiles));
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
payload: { mediaFiles: [{ id: '1' }] },
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
});
});
it('should wait if media library is not loaded', () => {
const { waitUntil } = require('../waitUntil');
waitUntil.mockImplementation(payload => ({ type: 'WAIT_UNTIL', ...payload }));
const store = mockStore({
mediaLibrary: Map({}),
});
const mediaFiles = [{ id: '1' }];
store.dispatch(addMediaFilesToLibrary(mediaFiles));
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
type: 'WAIT_UNTIL',
predicate: expect.any(Function),
run: expect.any(Function),
});
expect(actions[0].predicate({ type: 'MEDIA_LOAD_SUCCESS' })).toBe(true);
expect(actions[0].run(store.dispatch)).toEqual({
payload: { mediaFiles: [{ id: '1' }] },
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
});
});
});
});

View File

@ -3,11 +3,20 @@ import { actions as notifActions } from 'redux-notifications';
import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
import { serializeValues } from 'Lib/serializeEntryValues';
import { currentBackend } from 'coreSrc/backend';
import { getAsset, selectPublishedSlugs, selectUnpublishedSlugs } from 'Reducers';
import { selectPublishedSlugs, selectUnpublishedSlugs } from 'Reducers';
import { selectFields } from 'Reducers/collections';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util';
import { loadEntry } from './entries';
import {
loadEntry,
getMediaAssets,
setDraftEntryMediaFiles,
clearDraftEntryMediaFiles,
} from './entries';
import { createAssetProxy } from 'ValueObjects/AssetProxy';
import { addAssets } from './media';
import { addMediaFilesToLibrary } from './mediaLibrary';
import ValidationErrorTypes from 'Constants/validationErrorTypes';
const { notifSend } = notifActions;
@ -230,30 +239,55 @@ function unpublishedEntryDeleteError(collection, slug, transactionID) {
*/
export function loadUnpublishedEntry(collection, slug) {
return (dispatch, getState) => {
return async (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(unpublishedEntryLoading(collection, slug));
backend
.unpublishedEntry(collection, slug)
.then(entry => dispatch(unpublishedEntryLoaded(collection, entry)))
.catch(error => {
if (error.name === EDITORIAL_WORKFLOW_ERROR && error.notUnderEditorialWorkflow) {
dispatch(unpublishedEntryRedirected(collection, slug));
dispatch(loadEntry(collection, slug));
} else {
dispatch(
notifSend({
message: {
key: 'ui.toast.onFailToLoadEntries',
details: error,
},
kind: 'danger',
dismissAfter: 8000,
}),
);
}
});
try {
const entry = await backend.unpublishedEntry(collection, slug);
const mediaFiles = entry.mediaFiles;
const assetProxies = await Promise.all(
mediaFiles.map(({ file }) => createAssetProxy(file.name, file)),
);
dispatch(addAssets(assetProxies));
dispatch(
setDraftEntryMediaFiles(
assetProxies.map((asset, index) => ({
...asset,
...mediaFiles[index],
draft: true,
})),
),
);
dispatch(
addMediaFilesToLibrary(
mediaFiles.map(file => ({
...file,
draft: true,
})),
),
);
dispatch(unpublishedEntryLoaded(collection, entry));
} catch (error) {
if (error.name === EDITORIAL_WORKFLOW_ERROR && error.notUnderEditorialWorkflow) {
dispatch(unpublishedEntryRedirected(collection, slug));
dispatch(loadEntry(collection, slug));
} else {
dispatch(
notifSend({
message: {
key: 'ui.toast.onFailToLoadEntries',
details: error,
},
kind: 'danger',
dismissAfter: 8000,
}),
);
}
}
};
}
@ -314,7 +348,7 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
const backend = currentBackend(state.config);
const transactionID = uuid();
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
const assetProxies = getMediaAssets(state, entryDraft.get('mediaFiles'));
const entry = entryDraft.get('entry');
/**
@ -455,7 +489,7 @@ export function publishUnpublishedEntry(collection, slug) {
dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID));
return backend
.publishUnpublishedEntry(collection, slug)
.then(() => {
.then(({ mediaFiles }) => {
dispatch(
notifSend({
message: { key: 'ui.toast.entryPublished' },
@ -463,8 +497,12 @@ export function publishUnpublishedEntry(collection, slug) {
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryPublished(collection, slug, transactionID));
dispatch(loadEntry(collections.get(collection), slug));
dispatch(addMediaFilesToLibrary(mediaFiles.map(file => ({ ...file, draft: false }))));
dispatch(clearDraftEntryMediaFiles());
})
.catch(error => {
dispatch(

View File

@ -9,7 +9,10 @@ import { selectFields } from 'Reducers/collections';
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
import { Cursor } from 'netlify-cms-lib-util';
import { createEntry } from 'ValueObjects/Entry';
import { createAssetProxy } from 'ValueObjects/AssetProxy';
import ValidationErrorTypes from 'Constants/validationErrorTypes';
import { deleteMedia, addMediaFilesToLibrary } from './mediaLibrary';
import { addAssets } from './media';
const { notifSend } = notifActions;
@ -42,6 +45,11 @@ export const ENTRY_DELETE_REQUEST = 'ENTRY_DELETE_REQUEST';
export const ENTRY_DELETE_SUCCESS = 'ENTRY_DELETE_SUCCESS';
export const ENTRY_DELETE_FAILURE = 'ENTRY_DELETE_FAILURE';
export const ADD_DRAFT_ENTRY_MEDIA_FILE = 'ADD_DRAFT_ENTRY_MEDIA_FILE';
export const SET_DRAFT_ENTRY_MEDIA_FILES = 'SET_DRAFT_ENTRY_MEDIA_FILES';
export const REMOVE_DRAFT_ENTRY_MEDIA_FILE = 'REMOVE_DRAFT_ENTRY_MEDIA_FILE';
export const CLEAR_DRAFT_ENTRY_MEDIA_FILES = 'CLEAR_DRAFT_ENTRY_MEDIA_FILES';
/*
* Simple Action Creators (Internal)
* We still need to export them for tests
@ -185,16 +193,24 @@ export function emptyDraftCreated(entry) {
/*
* Exported simple Action Creators
*/
export function createDraftFromEntry(entry, metadata) {
export function createDraftFromEntry(entry, metadata, mediaFiles) {
return {
type: DRAFT_CREATE_FROM_ENTRY,
payload: { entry, metadata },
payload: { entry, metadata, mediaFiles },
};
}
export function discardDraft() {
return {
type: DRAFT_DISCARD,
return (dispatch, getState) => {
const state = getState();
const mediaDrafts = state.entryDraft.get('mediaFiles').filter(file => file.draft);
mediaDrafts.forEach(file => {
dispatch(deleteMedia(file));
});
dispatch({ type: DRAFT_DISCARD });
};
}
@ -223,24 +239,55 @@ export function clearFieldErrors() {
return { type: DRAFT_CLEAR_ERRORS };
}
export function localBackupRetrieved(entry) {
export function localBackupRetrieved(entry, mediaFiles) {
return {
type: DRAFT_LOCAL_BACKUP_RETRIEVED,
payload: { entry },
payload: { entry, mediaFiles },
};
}
export function loadLocalBackup() {
return {
type: DRAFT_CREATE_FROM_LOCAL_BACKUP,
return (dispatch, getState) => {
dispatch({
type: DRAFT_CREATE_FROM_LOCAL_BACKUP,
});
// only add media files to the library after loading from backup was approved
const state = getState();
const mediaFiles = state.entryDraft.get('mediaFiles').toJS();
const filesToAdd = mediaFiles.map(file => ({
...file,
draft: true,
}));
dispatch(addMediaFilesToLibrary(filesToAdd));
};
}
export function persistLocalBackup(entry, collection) {
export function addDraftEntryMediaFile(file) {
return { type: ADD_DRAFT_ENTRY_MEDIA_FILE, payload: file };
}
export function setDraftEntryMediaFiles(files) {
return { type: SET_DRAFT_ENTRY_MEDIA_FILES, payload: files };
}
export function removeDraftEntryMediaFile(file) {
return { type: REMOVE_DRAFT_ENTRY_MEDIA_FILE, payload: file };
}
export function clearDraftEntryMediaFiles() {
return { type: CLEAR_DRAFT_ENTRY_MEDIA_FILES };
}
export function persistLocalBackup(entry, collection, mediaFiles) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
return backend.persistLocalDraftBackup(entry, collection);
// persist any pending related media files and assets
const assets = getMediaAssets(state, mediaFiles);
return backend.persistLocalDraftBackup(entry, collection, mediaFiles, assets);
};
}
@ -248,9 +295,16 @@ export function retrieveLocalBackup(collection, slug) {
return async (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
const entry = await backend.getLocalDraftBackup(collection, slug);
const { entry, mediaFiles, assets } = await backend.getLocalDraftBackup(collection, slug);
if (entry) {
return dispatch(localBackupRetrieved(entry));
// load assets from backup
const assetProxies = await Promise.all(
assets.map(asset => createAssetProxy(asset.value, asset.fileObj)),
);
dispatch(addAssets(assetProxies));
return dispatch(localBackupRetrieved(entry, mediaFiles));
}
};
}
@ -462,6 +516,10 @@ export function createEmptyDraftData(fields, withNameKey = true) {
}, {});
}
export function getMediaAssets(state, mediaFiles) {
return mediaFiles.map(file => getAsset(state, file.public_path));
}
export function persistEntry(collection) {
return (dispatch, getState) => {
const state = getState();
@ -491,7 +549,7 @@ export function persistEntry(collection) {
}
const backend = currentBackend(state.config);
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
const assetProxies = getMediaAssets(state, entryDraft.get('mediaFiles'));
const entry = entryDraft.get('entry');
/**

View File

@ -1,6 +1,11 @@
export const ADD_ASSETS = 'ADD_ASSETS';
export const ADD_ASSET = 'ADD_ASSET';
export const REMOVE_ASSET = 'REMOVE_ASSET';
export function addAssets(assets) {
return { type: ADD_ASSETS, payload: assets };
}
export function addAsset(assetProxy) {
return { type: ADD_ASSET, payload: assetProxy };
}

View File

@ -2,11 +2,14 @@ import { Map } from 'immutable';
import { actions as notifActions } from 'redux-notifications';
import { resolveMediaFilename, getBlobSHA } from 'netlify-cms-lib-util';
import { currentBackend } from 'coreSrc/backend';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import { createAssetProxy } from 'ValueObjects/AssetProxy';
import { selectIntegration } from 'Reducers';
import { getIntegrationProvider } from 'Integrations';
import { addAsset } from './media';
import { addAsset, removeAsset } from './media';
import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries';
import { sanitizeSlug } from 'Lib/urlHelper';
import { waitUntil } from './waitUntil';
const { notifSend } = notifActions;
@ -27,6 +30,7 @@ export const MEDIA_DELETE_FAILURE = 'MEDIA_DELETE_FAILURE';
export const MEDIA_DISPLAY_URL_REQUEST = 'MEDIA_DISPLAY_URL_REQUEST';
export const MEDIA_DISPLAY_URL_SUCCESS = 'MEDIA_DISPLAY_URL_SUCCESS';
export const MEDIA_DISPLAY_URL_FAILURE = 'MEDIA_DISPLAY_URL_FAILURE';
export const ADD_MEDIA_FILES_TO_LIBRARY = 'ADD_MEDIA_FILES_TO_LIBRARY';
export function createMediaLibrary(instance) {
const api = {
@ -195,14 +199,41 @@ export function persistMedia(file, opts = {}) {
const id = await getBlobSHA(file);
const assetProxy = await createAssetProxy(fileName, file, false, privateUpload);
dispatch(addAsset(assetProxy));
const entry = state.entryDraft.get('entry');
const useWorkflow = state.config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW;
const draft = entry && !entry.isEmpty() && useWorkflow;
if (!integration) {
const asset = await backend.persistMedia(state.config, assetProxy);
const asset = await backend.persistMedia(state.config, assetProxy, draft);
const assetId = asset.id || id;
const displayURL = asset.displayURL || URL.createObjectURL(file);
return dispatch(mediaPersisted({ id, displayURL, ...asset }));
if (draft) {
dispatch(
addDraftEntryMediaFile({
...asset,
id: assetId,
draft,
public_path: assetProxy.public_path,
}),
);
}
return dispatch(
mediaPersisted({
...asset,
id: assetId,
displayURL,
draft,
}),
);
}
return dispatch(
mediaPersisted(
{ id, displayURL: URL.createObjectURL(file), ...assetProxy.asset },
{ id, displayURL: URL.createObjectURL(file), ...assetProxy.asset, draft },
{ privateUpload },
),
);
@ -222,37 +253,18 @@ export function persistMedia(file, opts = {}) {
export function deleteMedia(file, opts = {}) {
const { privateUpload } = opts;
return (dispatch, getState) => {
return async (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, null, 'assetStore');
if (integration) {
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
dispatch(mediaDeleting());
return provider
.delete(file.id)
.then(() => {
return dispatch(mediaDeleted(file, { privateUpload }));
})
.catch(error => {
console.error(error);
dispatch(
notifSend({
message: `Failed to delete media: ${error.message}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
return dispatch(mediaDeleteFailed({ privateUpload }));
});
}
dispatch(mediaDeleting());
return backend
.deleteMedia(state.config, file.path)
.then(() => {
return dispatch(mediaDeleted(file));
})
.catch(error => {
try {
await provider.delete(file.id);
return dispatch(mediaDeleted(file, { privateUpload }));
} catch (error) {
console.error(error);
dispatch(
notifSend({
@ -261,8 +273,32 @@ export function deleteMedia(file, opts = {}) {
dismissAfter: 8000,
}),
);
return dispatch(mediaDeleteFailed());
});
return dispatch(mediaDeleteFailed({ privateUpload }));
}
}
dispatch(mediaDeleting());
try {
const assetProxy = await createAssetProxy(file.name, file);
dispatch(removeAsset(assetProxy.public_path));
dispatch(removeDraftEntryMediaFile({ id: file.id }));
if (!file.draft) {
await backend.deleteMedia(state.config, file.path);
}
return dispatch(mediaDeleted(file));
} catch (error) {
console.error(error);
dispatch(
notifSend({
message: `Failed to delete media: ${error.message}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
return dispatch(mediaDeleteFailed());
}
};
}
@ -335,6 +371,27 @@ export function mediaPersisted(asset, opts = {}) {
};
}
export function addMediaFilesToLibrary(mediaFiles) {
return (dispatch, getState) => {
const state = getState();
const action = {
type: ADD_MEDIA_FILES_TO_LIBRARY,
payload: { mediaFiles },
};
// add media files to library only after the library finished loading
if (state.mediaLibrary.get('isLoading') === false) {
dispatch(action);
} else {
dispatch(
waitUntil({
predicate: ({ type }) => type === MEDIA_LOAD_SUCCESS,
run: dispatch => dispatch(action),
}),
);
}
};
}
export function mediaPersistFailed(error, opts = {}) {
const { privateUpload } = opts;
return { type: MEDIA_PERSIST_FAILURE, payload: { privateUpload } };

View File

@ -0,0 +1,9 @@
import { WAIT_UNTIL_ACTION } from '../redux/middleware/waitUntilAction';
export function waitUntil({ predicate, run }) {
return {
type: WAIT_UNTIL_ACTION,
predicate,
run,
};
}

View File

@ -402,22 +402,31 @@ export class Backend {
const key = getEntryBackupKey(collection.get('name'), slug);
const backup = await localForage.getItem(key);
if (!backup || !backup.raw.trim()) {
return;
return {};
}
const { raw, path } = backup;
const { raw, path, mediaFiles = [], assets = [] } = backup;
const label = selectFileEntryLabel(collection, slug);
return this.entryWithFormat(collection, slug)(
const entry = this.entryWithFormat(collection, slug)(
createEntry(collection.get('name'), slug, path, { raw, label }),
);
return { entry, mediaFiles, assets };
}
async persistLocalDraftBackup(entry, collection) {
async persistLocalDraftBackup(entry, collection, mediaFiles, assets) {
const key = getEntryBackupKey(collection.get('name'), entry.get('slug'));
const raw = this.entryToRaw(collection, entry);
if (!raw.trim()) {
return;
}
await localForage.setItem(key, { raw, path: entry.get('path') });
await localForage.setItem(key, {
raw,
path: entry.get('path'),
mediaFiles: mediaFiles.toJS(),
assets: assets.toJS(),
});
return localForage.setItem(getEntryBackupKey(), raw);
}
@ -511,6 +520,7 @@ export class Backend {
isModification: loadedEntry.isModification,
});
entry.metaData = loadedEntry.metaData;
entry.mediaFiles = loadedEntry.mediaFiles;
return entry;
})
.then(this.entryWithFormat(collection, slug));
@ -663,7 +673,7 @@ export class Backend {
return this.implementation.persistEntry(entryObj, MediaFiles, opts).then(() => entryObj.slug);
}
async persistMedia(config, file) {
async persistMedia(config, file, draft) {
const user = await this.currentUser();
const options = {
commitMessage: commitMessageFormatter(
@ -676,6 +686,7 @@ export class Backend {
},
user.useOpenAuthoring,
),
draft,
};
return this.implementation.persistMedia(file, options);
}

View File

@ -41,7 +41,7 @@ const navigateToNewEntry = collectionName => navigateCollection(`${collectionNam
const navigateToEntry = (collectionName, slug) =>
navigateCollection(`${collectionName}/entries/${slug}`);
class Editor extends React.Component {
export class Editor extends React.Component {
static propTypes = {
boundGetAsset: PropTypes.func.isRequired,
changeDraftField: PropTypes.func.isRequired,
@ -79,10 +79,10 @@ class Editor extends React.Component {
}),
hasChanged: PropTypes.bool,
t: PropTypes.func.isRequired,
retrieveLocalBackup: PropTypes.func,
localBackup: PropTypes.bool,
retrieveLocalBackup: PropTypes.func.isRequired,
localBackup: ImmutablePropTypes.map,
loadLocalBackup: PropTypes.func,
persistLocalBackup: PropTypes.func,
persistLocalBackup: PropTypes.func.isRequired,
deleteLocalBackup: PropTypes.func,
};
@ -190,7 +190,11 @@ class Editor extends React.Component {
}
if (this.props.hasChanged) {
this.createBackup(this.props.entryDraft.get('entry'), this.props.collection);
this.createBackup(
this.props.entryDraft.get('entry'),
this.props.collection,
this.props.entryDraft.get('mediaFiles'),
);
}
if (prevProps.entry === this.props.entry) return;
@ -205,7 +209,8 @@ class Editor extends React.Component {
const values = deserializeValues(entry.get('data'), fields);
const deserializedEntry = entry.set('data', values);
const fieldsMetaData = this.props.entryDraft && this.props.entryDraft.get('fieldsMetaData');
this.createDraft(deserializedEntry, fieldsMetaData);
const mediaFiles = this.props.entryDraft && this.props.entryDraft.get('mediaFiles');
this.createDraft(deserializedEntry, fieldsMetaData, mediaFiles);
} else if (newEntry) {
prevProps.createEmptyDraft(collection);
}
@ -217,12 +222,12 @@ class Editor extends React.Component {
window.removeEventListener('beforeunload', this.exitBlocker);
}
createBackup = debounce(function(entry, collection) {
this.props.persistLocalBackup(entry, collection);
createBackup = debounce(function(entry, collection, mediaFiles) {
this.props.persistLocalBackup(entry, collection, mediaFiles);
}, 2000);
createDraft = (entry, metadata) => {
if (entry) this.props.createDraftFromEntry(entry, metadata);
createDraft = (entry, metadata, mediaFiles) => {
if (entry) this.props.createDraftFromEntry(entry, metadata, mediaFiles);
};
handleChangeStatus = newStatusName => {

View File

@ -0,0 +1,247 @@
import React from 'react';
import { Editor } from '../Editor';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
jest.mock('lodash/debounce', () => {
const flush = jest.fn();
return func => {
func.flush = flush;
return func;
};
});
jest.mock('../EditorInterface', () => props => <mock-editor-interface {...props} />);
jest.mock('netlify-cms-ui-default', () => {
return {
// eslint-disable-next-line react/display-name
Loader: props => <mock-loader {...props} />,
};
});
jest.mock('Routing/history');
describe('Editor', () => {
const props = {
boundGetAsset: jest.fn(),
changeDraftField: jest.fn(),
changeDraftFieldValidation: jest.fn(),
collection: fromJS({ name: 'posts' }),
createDraftFromEntry: jest.fn(),
createEmptyDraft: jest.fn(),
discardDraft: jest.fn(),
entry: fromJS({}),
entryDraft: fromJS({}),
loadEntry: jest.fn(),
persistEntry: jest.fn(),
deleteEntry: jest.fn(),
showDelete: true,
fields: fromJS([]),
slug: 'slug',
newEntry: true,
updateUnpublishedEntryStatus: jest.fn(),
publishUnpublishedEntry: jest.fn(),
deleteUnpublishedEntry: jest.fn(),
logoutUser: jest.fn(),
loadEntries: jest.fn(),
deployPreview: fromJS({}),
loadDeployPreview: jest.fn(),
user: fromJS({}),
t: jest.fn(key => key),
localBackup: fromJS({}),
retrieveLocalBackup: jest.fn(),
persistLocalBackup: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render loader when entryDraft is null', () => {
// suppress prop type error
jest.spyOn(console, 'error').mockImplementation(() => {});
const { asFragment } = render(<Editor {...props} entryDraft={null} />);
expect(asFragment()).toMatchSnapshot();
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
'Warning: Failed prop type: Required prop `entryDraft` was not specified in `Editor`.\n in Editor',
);
});
it('should render loader when entryDraft entry is undefined', () => {
const { asFragment } = render(<Editor {...props} entryDraft={fromJS({})} />);
expect(asFragment()).toMatchSnapshot();
});
it('should render loader when entry is fetching', () => {
const { asFragment } = render(
<Editor {...props} entryDraft={fromJS({ entry: {} })} entry={fromJS({ isFetching: true })} />,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render editor interface when entry is not fetching', () => {
const { asFragment } = render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
/>,
);
expect(asFragment()).toMatchSnapshot();
});
it('should call retrieveLocalBackup on mount', () => {
render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
/>,
);
expect(props.retrieveLocalBackup).toHaveBeenCalledTimes(1);
expect(props.retrieveLocalBackup).toHaveBeenCalledWith(props.collection, props.slug);
});
it('should create new draft on new entry when mounting', () => {
render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
newEntry={true}
/>,
);
expect(props.createEmptyDraft).toHaveBeenCalledTimes(1);
expect(props.createEmptyDraft).toHaveBeenCalledWith(props.collection);
expect(props.loadEntry).toHaveBeenCalledTimes(0);
});
it('should load entry on existing entry when mounting', () => {
render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
newEntry={false}
/>,
);
expect(props.createEmptyDraft).toHaveBeenCalledTimes(0);
expect(props.loadEntry).toHaveBeenCalledTimes(1);
expect(props.loadEntry).toHaveBeenCalledWith(props.collection, 'slug');
});
it('should load entires when entries are not loaded when mounting', () => {
render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
collectionEntriesLoaded={false}
/>,
);
expect(props.loadEntries).toHaveBeenCalledTimes(1);
expect(props.loadEntries).toHaveBeenCalledWith(props.collection);
});
it('should not load entires when entries are loaded when mounting', () => {
render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
collectionEntriesLoaded={true}
/>,
);
expect(props.loadEntries).toHaveBeenCalledTimes(0);
});
it('should flush debounce createBackup, discard draft and remove exit blocker on umount', () => {
window.removeEventListener = jest.fn();
const debounce = require('lodash/debounce');
const flush = debounce({}).flush;
const { unmount } = render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' }, hasChanged: true })}
entry={fromJS({ isFetching: false })}
/>,
);
jest.clearAllMocks();
unmount();
expect(flush).toHaveBeenCalledTimes(1);
expect(props.discardDraft).toHaveBeenCalledTimes(1);
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
const callback = window.removeEventListener.mock.calls.find(
call => call[0] === 'beforeunload',
)[1];
const event = {};
callback(event);
expect(event).toEqual({ returnValue: 'editor.editor.onLeavePage' });
});
it('should persist backup when changed', () => {
const { rerender } = render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
/>,
);
jest.clearAllMocks();
rerender(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' }, mediaFiles: [{ id: '1' }] })}
entry={fromJS({ isFetching: false })}
hasChanged={true}
/>,
);
expect(props.persistLocalBackup).toHaveBeenCalledTimes(1);
expect(props.persistLocalBackup).toHaveBeenCalledWith(
fromJS({ slug: 'slug' }),
props.collection,
fromJS([{ id: '1' }]),
);
});
it('should create draft from entry when done fetching', () => {
const { rerender } = render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
/>,
);
jest.clearAllMocks();
rerender(
<Editor
{...props}
entryDraft={fromJS({
entry: { slug: 'slug' },
mediaFiles: [{ id: '1' }],
fieldsMetaData: {},
})}
entry={fromJS({ isFetching: false })}
/>,
);
expect(props.createDraftFromEntry).toHaveBeenCalledTimes(1);
expect(props.createDraftFromEntry).toHaveBeenCalledWith(
fromJS({ isFetching: false, data: {} }),
fromJS({}),
fromJS([{ id: '1' }]),
);
});
});

View File

@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Editor should render editor interface when entry is not fetching 1`] = `
<DocumentFragment>
<mock-editor-interface
collection="Map { \\"name\\": \\"posts\\" }"
deploypreview="Map {}"
entry="Map { \\"slug\\": \\"slug\\" }"
fields="List []"
isnewentry="true"
showdelete="true"
user="Map {}"
/>
</DocumentFragment>
`;
exports[`Editor should render loader when entry is fetching 1`] = `
<DocumentFragment>
<mock-loader
active="true"
>
editor.editor.loadingEntry
</mock-loader>
</DocumentFragment>
`;
exports[`Editor should render loader when entryDraft entry is undefined 1`] = `
<DocumentFragment>
<mock-loader
active="true"
>
editor.editor.loadingEntry
</mock-loader>
</DocumentFragment>
`;
exports[`Editor should render loader when entryDraft is null 1`] = `
<DocumentFragment>
<mock-loader
active="true"
>
editor.editor.loadingEntry
</mock-loader>
</DocumentFragment>
`;

View File

@ -118,7 +118,7 @@ class MediaLibrary extends React.Component {
toTableData = files => {
const tableData =
files &&
files.map(({ key, name, id, size, queryOrder, url, urlIsPublicPath, displayURL }) => {
files.map(({ key, name, id, size, queryOrder, url, urlIsPublicPath, displayURL, draft }) => {
const ext = fileExtension(name).toLowerCase();
return {
key,
@ -130,6 +130,7 @@ class MediaLibrary extends React.Component {
url,
urlIsPublicPath,
displayURL,
draft,
isImage: IMAGE_EXTENSIONS.includes(ext),
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
};

View File

@ -27,6 +27,7 @@ const CardImageWrapper = styled.div`
${effects.checkerboard};
${shadows.inset};
border-bottom: solid ${lengths.borderWidth} ${colors.textFieldBorder};
position: relative;
`;
const CardImage = styled.img`
@ -53,6 +54,14 @@ const CardText = styled.p`
line-height: 1.3 !important;
`;
const DraftText = styled.p`
color: ${colors.mediaDraftText};
background-color: ${colors.mediaDraftBackground};
position: absolute;
padding: 8px;
border-radius: ${lengths.borderRadius} 0px ${lengths.borderRadius} 0;
`;
class MediaLibraryCard extends React.Component {
render() {
const {
@ -60,11 +69,13 @@ class MediaLibraryCard extends React.Component {
displayURL,
text,
onClick,
draftText,
width,
margin,
isPrivate,
type,
isViewableImage,
isDraft,
} = this.props;
const url = displayURL.get('url');
return (
@ -77,7 +88,12 @@ class MediaLibraryCard extends React.Component {
isPrivate={isPrivate}
>
<CardImageWrapper>
{url && isViewableImage ? <CardImage src={url} /> : <CardFileIcon>{type}</CardFileIcon>}
{isDraft ? <DraftText data-testid="draft-text">{draftText}</DraftText> : null}
{url && isViewableImage ? (
<CardImage src={url} />
) : (
<CardFileIcon data-testid="card-file-icon">{type}</CardFileIcon>
)}
</CardImageWrapper>
<CardText>{text}</CardText>
</Card>
@ -96,12 +112,14 @@ MediaLibraryCard.propTypes = {
displayURL: ImmutablePropTypes.map.isRequired,
text: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
draftText: PropTypes.string.isRequired,
width: PropTypes.string.isRequired,
margin: PropTypes.string.isRequired,
isPrivate: PropTypes.bool,
type: PropTypes.string,
isViewableImage: PropTypes.bool.isRequired,
loadDisplayURL: PropTypes.func.isRequired,
isDraft: PropTypes.bool,
};
export default MediaLibraryCard;

View File

@ -32,6 +32,7 @@ const MediaLibraryCardGrid = ({
onLoadMore,
isPaginating,
paginatingMessage,
cardDraftText,
cardWidth,
cardMargin,
isPrivate,
@ -46,6 +47,8 @@ const MediaLibraryCardGrid = ({
isSelected={isSelectedFile(file)}
text={file.name}
onClick={() => onAssetClick(file)}
isDraft={file.draft}
draftText={cardDraftText}
width={cardWidth}
margin={cardMargin}
isPrivate={isPrivate}
@ -74,6 +77,7 @@ MediaLibraryCardGrid.propTypes = {
type: PropTypes.string.isRequired,
url: PropTypes.string,
urlIsPublicPath: PropTypes.bool,
draft: PropTypes.bool,
}),
).isRequired,
isSelectedFile: PropTypes.func.isRequired,
@ -82,6 +86,7 @@ MediaLibraryCardGrid.propTypes = {
onLoadMore: PropTypes.func.isRequired,
isPaginating: PropTypes.bool,
paginatingMessage: PropTypes.string,
cardDraftText: PropTypes.string.isRequired,
cardWidth: PropTypes.string.isRequired,
cardMargin: PropTypes.string.isRequired,
loadDisplayURL: PropTypes.func.isRequired,

View File

@ -170,6 +170,7 @@ const MediaLibraryModal = ({
onLoadMore={handleLoadMore}
isPaginating={isPaginating}
paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')}
cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')}
cardWidth={cardWidth}
cardMargin={cardMargin}
isPrivate={privateUpload}

View File

@ -0,0 +1,47 @@
import React from 'react';
import { Map } from 'immutable';
import MediaLibraryCard from '../MediaLibraryCard';
import { render } from '@testing-library/react';
describe('MediaLibraryCard', () => {
const props = {
displayURL: Map({ url: 'url' }),
text: 'image.png',
onClick: jest.fn(),
draftText: 'Draft',
width: '100px',
margin: '10px',
isViewableImage: true,
loadDisplayURL: jest.fn(),
};
it('should match snapshot for non draft image', () => {
const { asFragment, queryByTestId } = render(<MediaLibraryCard {...props} />);
expect(queryByTestId('draft-text')).toBeNull();
expect(asFragment()).toMatchSnapshot();
});
it('should match snapshot for draft image', () => {
const { asFragment, getByTestId } = render(<MediaLibraryCard {...props} isDraft={true} />);
expect(getByTestId('draft-text')).toHaveTextContent('Draft');
expect(asFragment()).toMatchSnapshot();
});
it('should match snapshot for non viewable image', () => {
const { asFragment, getByTestId } = render(
<MediaLibraryCard {...props} isViewableImage={false} type="Not Viewable" />,
);
expect(getByTestId('card-file-icon')).toHaveTextContent('Not Viewable');
expect(asFragment()).toMatchSnapshot();
});
it('should call loadDisplayURL on mount when url is empty', () => {
const loadDisplayURL = jest.fn();
render(
<MediaLibraryCard {...props} loadDisplayURL={loadDisplayURL} displayURL={Map({ url: '' })} />,
);
expect(loadDisplayURL).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,211 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MediaLibraryCard should match snapshot for draft image 1`] = `
<DocumentFragment>
.emotion-8 {
width: 100px;
height: 240px;
margin: 10px;
border: solid 2px #dfdfe3;
border-radius: 5px;
cursor: pointer;
overflow: hidden;
}
.emotion-8:focus {
outline: none;
}
.emotion-4 {
height: 162px;
background-color: #f2f2f2;
background-size: 16px 16px;
background-position: 0 0,8px 8px;
background-image: linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 ) , linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 );
box-shadow: inset 0 0 4px rgba(68,74,87,0.3);
border-bottom: solid 2px #dfdfe3;
position: relative;
}
.emotion-2 {
width: 100%;
height: 160px;
object-fit: contain;
border-radius: 2px 2px 0 0;
}
.emotion-6 {
color: #798291;
padding: 8px;
margin-top: 20px;
overflow-wrap: break-word;
line-height: 1.3 !important;
}
.emotion-0 {
color: #70399f;
background-color: #f6d8ff;
position: absolute;
padding: 8px;
border-radius: 5px 0px 5px 0;
}
<div
class="emotion-8 emotion-9"
tabindex="-1"
width="100px"
>
<div
class="emotion-4 emotion-5"
>
<p
class="emotion-0 emotion-1"
data-testid="draft-text"
>
Draft
</p>
<img
class="emotion-2 emotion-3"
src="url"
/>
</div>
<p
class="emotion-6 emotion-7"
>
image.png
</p>
</div>
</DocumentFragment>
`;
exports[`MediaLibraryCard should match snapshot for non draft image 1`] = `
<DocumentFragment>
.emotion-6 {
width: 100px;
height: 240px;
margin: 10px;
border: solid 2px #dfdfe3;
border-radius: 5px;
cursor: pointer;
overflow: hidden;
}
.emotion-6:focus {
outline: none;
}
.emotion-2 {
height: 162px;
background-color: #f2f2f2;
background-size: 16px 16px;
background-position: 0 0,8px 8px;
background-image: linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 ) , linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 );
box-shadow: inset 0 0 4px rgba(68,74,87,0.3);
border-bottom: solid 2px #dfdfe3;
position: relative;
}
.emotion-0 {
width: 100%;
height: 160px;
object-fit: contain;
border-radius: 2px 2px 0 0;
}
.emotion-4 {
color: #798291;
padding: 8px;
margin-top: 20px;
overflow-wrap: break-word;
line-height: 1.3 !important;
}
<div
class="emotion-6 emotion-7"
tabindex="-1"
width="100px"
>
<div
class="emotion-2 emotion-3"
>
<img
class="emotion-0 emotion-1"
src="url"
/>
</div>
<p
class="emotion-4 emotion-5"
>
image.png
</p>
</div>
</DocumentFragment>
`;
exports[`MediaLibraryCard should match snapshot for non viewable image 1`] = `
<DocumentFragment>
.emotion-6 {
width: 100px;
height: 240px;
margin: 10px;
border: solid 2px #dfdfe3;
border-radius: 5px;
cursor: pointer;
overflow: hidden;
}
.emotion-6:focus {
outline: none;
}
.emotion-2 {
height: 162px;
background-color: #f2f2f2;
background-size: 16px 16px;
background-position: 0 0,8px 8px;
background-image: linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 ) , linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 );
box-shadow: inset 0 0 4px rgba(68,74,87,0.3);
border-bottom: solid 2px #dfdfe3;
position: relative;
}
.emotion-4 {
color: #798291;
padding: 8px;
margin-top: 20px;
overflow-wrap: break-word;
line-height: 1.3 !important;
}
.emotion-0 {
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 2px 2px 0 0;
padding: 1em;
font-size: 3em;
}
<div
class="emotion-6 emotion-7"
tabindex="-1"
width="100px"
>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-0 emotion-1"
data-testid="card-file-icon"
>
Not Viewable
</div>
</div>
<p
class="emotion-4 emotion-5"
>
image.png
</p>
</div>
</DocumentFragment>
`;

View File

@ -2,7 +2,7 @@ import { Map, List, fromJS } from 'immutable';
import * as actions from 'Actions/entries';
import reducer from '../entryDraft';
let initialState = Map({
const initialState = Map({
entry: Map(),
mediaFiles: List(),
fieldsMetaData: Map(),
@ -62,6 +62,8 @@ describe('entryDraft reducer', () => {
});
describe('persisting', () => {
let initialState;
beforeEach(() => {
initialState = fromJS({
entities: {
@ -111,4 +113,95 @@ describe('entryDraft reducer', () => {
expect(newState.getIn(['entry', 'isPersisting'])).toBeUndefined();
});
});
describe('REMOVE_DRAFT_ENTRY_MEDIA_FILE', () => {
it('should remove a media file', () => {
const actualState = reducer(
initialState.set('mediaFiles', List([{ id: '1' }, { id: '2' }])),
actions.removeDraftEntryMediaFile({ id: '1' }),
);
expect(actualState.toJS()).toEqual({
entry: {},
mediaFiles: [{ id: '2' }],
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
});
});
});
describe('ADD_DRAFT_ENTRY_MEDIA_FILE', () => {
it('should overwrite an existing media file', () => {
const actualState = reducer(
initialState.set('mediaFiles', List([{ id: '1', name: 'old' }])),
actions.addDraftEntryMediaFile({ id: '1', name: 'new' }),
);
expect(actualState.toJS()).toEqual({
entry: {},
mediaFiles: [{ id: '1', name: 'new' }],
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
});
});
});
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,
});
});
});
describe('DRAFT_CREATE_FROM_LOCAL_BACKUP', () => {
it('should create draft from local backup', () => {
const localBackup = Map({ entry: fromJS(entry), mediaFiles: List([{ id: '1' }]) });
const actualState = reducer(initialState.set('localBackup', localBackup), {
type: actions.DRAFT_CREATE_FROM_LOCAL_BACKUP,
});
expect(actualState.toJS()).toEqual({
entry: {
...entry,
newRecord: false,
},
mediaFiles: [{ id: '1' }],
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: true,
});
});
});
describe('DRAFT_LOCAL_BACKUP_RETRIEVED', () => {
it('should set local backup', () => {
const mediaFiles = [{ id: '1' }];
const actualState = reducer(initialState, actions.localBackupRetrieved(entry, mediaFiles));
expect(actualState.toJS()).toEqual({
entry: {},
mediaFiles: [],
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
localBackup: {
entry,
mediaFiles: [{ id: '1' }],
},
});
});
});
});

View File

@ -0,0 +1,67 @@
import { Map } from 'immutable';
import { ADD_MEDIA_FILES_TO_LIBRARY, mediaDeleted } from 'Actions/mediaLibrary';
import mediaLibrary from '../mediaLibrary';
jest.mock('uuid/v4');
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(
Map({
files: [{ key: 'key1' }, { key: 'key2' }],
}),
mediaDeleted({ key: 'key1' }),
),
).toEqual(
Map({
isDeleting: false,
files: [{ key: 'key2' }],
}),
);
});
it('should remove media file by id', () => {
expect(
mediaLibrary(
Map({
files: [{ id: 'id1' }, { id: 'id2' }],
}),
mediaDeleted({ id: 'id1' }),
),
).toEqual(
Map({
isDeleting: false,
files: [{ id: 'id2' }],
}),
);
});
});

View File

@ -0,0 +1,25 @@
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

@ -12,13 +12,16 @@ import {
ENTRY_PERSIST_SUCCESS,
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,
UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
UNPUBLISHED_ENTRY_PERSIST_FAILURE,
} from 'Actions/editorialWorkflow';
import { ADD_ASSET, REMOVE_ASSET } from 'Actions/media';
const initialState = Map({
entry: Map(),
@ -35,7 +38,7 @@ const entryDraftReducer = (state = Map(), action) => {
return state.withMutations(state => {
state.set('entry', action.payload.entry);
state.setIn(['entry', 'newRecord'], false);
state.set('mediaFiles', List());
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.
@ -56,19 +59,26 @@ const entryDraftReducer = (state = Map(), action) => {
case DRAFT_CREATE_FROM_LOCAL_BACKUP:
// Local Backup
return state.withMutations(state => {
const backupEntry = state.get('localBackup');
const backupDraftEntry = state.get('localBackup');
const backupEntry = backupDraftEntry.get('entry');
state.delete('localBackup');
state.set('entry', backupEntry);
state.setIn(['entry', 'newRecord'], !backupEntry.get('path'));
state.set('mediaFiles', List());
state.set('mediaFiles', backupDraftEntry.get('mediaFiles'));
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', true);
});
case DRAFT_DISCARD:
return initialState;
case DRAFT_LOCAL_BACKUP_RETRIEVED:
return state.set('localBackup', fromJS(action.payload.entry));
case DRAFT_LOCAL_BACKUP_RETRIEVED: {
const { entry, mediaFiles } = action.payload;
const newState = new Map({
entry: fromJS(entry),
mediaFiles: List(mediaFiles),
});
return state.set('localBackup', newState);
}
case DRAFT_CHANGE_FIELD:
return state.withMutations(state => {
state.setIn(['entry', 'data', action.payload.field], action.payload.value);
@ -113,14 +123,28 @@ const entryDraftReducer = (state = Map(), action) => {
state.set('hasChanged', false);
});
case ADD_ASSET:
case ADD_DRAFT_ENTRY_MEDIA_FILE:
if (state.has('mediaFiles')) {
return state.update('mediaFiles', list => list.push(action.payload.public_path));
return state.update('mediaFiles', list =>
list.filterNot(file => file.id === action.payload.id).push({ ...action.payload }),
);
}
return state;
case REMOVE_ASSET:
return state.update('mediaFiles', list => list.filterNot(path => path === action.payload));
case SET_DRAFT_ENTRY_MEDIA_FILES: {
return state.set('mediaFiles', List(action.payload));
}
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 CLEAR_DRAFT_ENTRY_MEDIA_FILES:
return state.set('mediaFiles', List());
default:
return state;

View File

@ -1,5 +1,6 @@
import { Map } from 'immutable';
import uuid from 'uuid/v4';
import { differenceBy } from 'lodash';
import {
MEDIA_LIBRARY_OPEN,
MEDIA_LIBRARY_CLOSE,
@ -18,6 +19,7 @@ import {
MEDIA_DISPLAY_URL_REQUEST,
MEDIA_DISPLAY_URL_SUCCESS,
MEDIA_DISPLAY_URL_FAILURE,
ADD_MEDIA_FILES_TO_LIBRARY,
} from 'Actions/mediaLibrary';
const defaultState = {
@ -127,6 +129,12 @@ 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) {
@ -143,7 +151,9 @@ const mediaLibrary = (state = Map(defaultState), action) => {
return state;
}
return state.withMutations(map => {
const updatedFiles = map.get('files').filter(file => file.key !== key);
const updatedFiles = map
.get('files')
.filter(file => (key ? file.key !== key : file.id !== id));
map.set('files', updatedFiles);
map.deleteIn(['displayURLs', id]);
map.set('isDeleting', false);

View File

@ -1,10 +1,17 @@
import { Map } from 'immutable';
import { resolvePath } from 'netlify-cms-lib-util';
import { ADD_ASSET, REMOVE_ASSET } from 'Actions/media';
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:

View File

@ -59,7 +59,7 @@ export function createAssetProxy(value, fileObj, uploaded = false, privateUpload
() => new AssetProxy(value, fileObj, false),
);
} else if (privateUpload) {
throw new Error('The Private Upload option is only avaible for Asset Store Integration');
throw new Error('The Private Upload option is only available for Asset Store Integration');
}
return Promise.resolve(new AssetProxy(value, fileObj, uploaded));